super-productivity/packages/super-sync-server/tests/server-security.spec.ts
Johannes Millan 70414145a3
fix(sync): prevent magic link prefetch failures and improve auth resilience (#7175)
* fix(sync): prevent magic link prefetch failures and improve auth resilience

- Make /magic-login prefetch-immune: GET now renders a confirmation page
  instead of consuming the single-use token. A "Log In" button triggers
  POST /api/login/magic-link/verify which does the actual verification.
  This prevents email client link prefetchers (Outlook SafeLinks, Gmail)
  from consuming magic link tokens before users click them.

- Increase rate limits on auth endpoints for shared-IP scenarios (e.g.
  university NAT). Global: 100→500, register: 10→50, login: 5→30,
  verify: 10→50, magic-login page: 10→50.

- Add trustProxy to Fastify so req.ip uses X-Forwarded-For behind
  reverse proxies instead of collapsing all clients to one rate bucket.

- Clear stale SuperSync auth tokens after 3 consecutive AuthFailSPErrors
  instead of never clearing them. Prevents persistent failure loops where
  an invalid token keeps retrying indefinitely until app reinstall.

- Improve registration-to-login UX messaging to emphasize email
  verification is required before login links will work.

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* chore: update package-lock.json from npm install

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): address review feedback for auth resilience

- Remove conflicting style.css link from magic-login page
- Change trustProxy from true to 1 (trust single hop only)
- Increase /login/magic-link rate limit from 30 to 50 per 15min
- Delete dead magic-login-redirect.js (replaced by magic-login-confirm.js)
- Reset auth failure counter on non-auth errors (truly consecutive)
- Add test for counter reset on non-auth error between auth errors

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): increase passkey rate limits and fix CSP inline script violations

- Increase passkey endpoint rate limits to match magic-link equivalents (50/15min)
- Extract inline scripts from /reset-password and /recover-passkey pages
  into external JS files to comply with CSP scriptSrc: ["'self'"]
- Remove dead safeJsonForScript helper (no longer needed)
- Increase /recover-passkey page rate limit to 50/15min for consistency

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): update security tests for escapeHtml and fix recover/passkey rate limit

- Update server-security.spec.ts: tests now check for escapeHtml output
  (<, ") instead of safeJsonForScript output (\u003c), matching
  the new data-token attribute approach
- Increase /recover/passkey rate limit from 30 to 50 per 15min to match
  /login/magic-link (both send email with a secret link)

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): reset auth failure counter after successful download

Move the counter reset from the final InSync return to right after
downloadRemoteOps() succeeds. This ensures early returns (cancelled,
LWW pending, payload rejected) also break the consecutive-failure
chain, since a successful download confirms auth is working.

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 14:46:57 +02:00

482 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import helmet from '@fastify/helmet';
describe('Server Security Configuration', () => {
describe('Content Security Policy', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = Fastify();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('should include CSP headers in response', async () => {
// Register helmet with the same config as the server
await app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"],
},
},
});
app.get('/test', async () => ({ status: 'ok' }));
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/test',
});
// Check that CSP header is present
const cspHeader = response.headers['content-security-policy'];
expect(cspHeader).toBeDefined();
// Verify key CSP directives
expect(cspHeader).toContain("default-src 'self'");
expect(cspHeader).toContain("script-src 'self'");
expect(cspHeader).toContain("object-src 'none'");
expect(cspHeader).toContain("frame-ancestors 'none'");
});
it('should include X-Frame-Options header', async () => {
await app.register(helmet);
app.get('/test', async () => ({ status: 'ok' }));
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/test',
});
// Helmet sets X-Frame-Options by default
expect(response.headers['x-frame-options']).toBeDefined();
});
it('should include X-Content-Type-Options header', async () => {
await app.register(helmet);
app.get('/test', async () => ({ status: 'ok' }));
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/test',
});
expect(response.headers['x-content-type-options']).toBe('nosniff');
});
});
describe('HTML Escape Function', () => {
// Test the escapeHtml function that prevents XSS in templates
const escapeHtml = (unsafe: string): string => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
it('should escape < and > characters', () => {
const input = '<script>alert("xss")</script>';
const escaped = escapeHtml(input);
expect(escaped).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
expect(escaped).not.toContain('<');
expect(escaped).not.toContain('>');
});
it('should escape ampersand', () => {
const input = 'Tom & Jerry';
const escaped = escapeHtml(input);
expect(escaped).toBe('Tom &amp; Jerry');
});
it('should escape double quotes', () => {
const input = 'He said "hello"';
const escaped = escapeHtml(input);
expect(escaped).toBe('He said &quot;hello&quot;');
});
it('should escape single quotes', () => {
const input = "It's a test";
const escaped = escapeHtml(input);
expect(escaped).toBe('It&#039;s a test');
});
it('should handle multiple special characters', () => {
const input = '<div class="test" data-value=\'a & b\'>content</div>';
const escaped = escapeHtml(input);
expect(escaped).toBe(
'&lt;div class=&quot;test&quot; data-value=&#039;a &amp; b&#039;&gt;content&lt;/div&gt;',
);
});
it('should handle empty string', () => {
expect(escapeHtml('')).toBe('');
});
it('should handle string with no special characters', () => {
const input = 'Hello World';
expect(escapeHtml(input)).toBe('Hello World');
});
it('should escape quotes to prevent attribute injection', () => {
// An attacker might try to break out of an attribute and add an event handler
const input = '" onmouseover="alert(1)"';
const escaped = escapeHtml(input);
// The quotes are escaped, so even though 'onmouseover' appears, it's harmless text
// because the quote before it is escaped and won't break out of the attribute
expect(escaped).toBe('&quot; onmouseover=&quot;alert(1)&quot;');
// The key protection is that " is escaped to &quot;
expect(escaped).not.toContain('"');
});
});
});
describe('Password Reset Page', () => {
let app: FastifyInstance;
beforeEach(async () => {
vi.resetModules();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('should render password reset form with token', async () => {
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/reset-password?token=test-token-123',
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/html');
const html = response.body;
expect(html).toContain('<title>Reset Password</title>');
expect(html).toContain('<form id="resetForm">');
expect(html).toContain('type="password"');
expect(html).toContain('Minimum 12 characters');
// Token should be escaped in the JavaScript
expect(html).toContain('test-token-123');
});
it('should return 400 when token is missing', async () => {
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/reset-password',
});
expect(response.statusCode).toBe(400);
expect(response.body).toBe('Token is required');
});
it('should escape malicious token in data attribute', async () => {
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
// Test attribute breakout attempt - double quotes should be escaped
const maliciousToken = '"><script>alert(1)</script>';
const response = await app.inject({
method: 'GET',
url: `/reset-password?token=${encodeURIComponent(maliciousToken)}`,
});
expect(response.statusCode).toBe(200);
const html = response.body;
// Token is in data-token attribute, escapeHtml prevents attribute breakout
expect(html).toContain('data-token="');
// The raw attack string should not appear unescaped
expect(html).not.toContain('"><script>');
// Double quotes are escaped as &quot;
expect(html).toContain('&quot;');
});
it('should escape script tags in token to prevent XSS', async () => {
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const maliciousToken = '</script><script>alert("xss")</script>';
const response = await app.inject({
method: 'GET',
url: `/reset-password?token=${encodeURIComponent(maliciousToken)}`,
});
expect(response.statusCode).toBe(200);
const html = response.body;
// escapeHtml escapes < as &lt; to prevent injection
expect(html).not.toContain('</script><script>');
expect(html).toContain('&lt;'); // < escaped as HTML entity
});
});
describe('Email Verification Page', () => {
let app: FastifyInstance;
beforeEach(async () => {
vi.resetModules();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('should await verifyEmail before sending response', async () => {
// Mock the verifyEmail function to track if it was awaited
let verifyEmailCompleted = false;
vi.doMock('../src/auth', () => ({
verifyEmail: vi.fn().mockImplementation(async () => {
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 10));
verifyEmailCompleted = true;
return true;
}),
}));
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/verify-email?token=valid-token',
});
// The response should only be sent after verifyEmail completes
expect(response.statusCode).toBe(200);
expect(verifyEmailCompleted).toBe(true);
expect(response.body).toContain('Email Verified');
});
it('should handle verification errors properly', async () => {
vi.doMock('../src/auth', () => ({
verifyEmail: vi.fn().mockRejectedValue(new Error('Invalid verification token')),
}));
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/verify-email?token=invalid-token',
});
expect(response.statusCode).toBe(400);
expect(response.body).toContain('Verification failed');
});
it('should return 400 when token is missing', async () => {
const { pageRoutes } = await import('../src/pages');
app = Fastify();
await app.register(pageRoutes, { prefix: '/' });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/verify-email',
});
expect(response.statusCode).toBe(400);
expect(response.body).toBe('Token is required');
});
});
describe('CORS with wildcard origins', () => {
let app: FastifyInstance;
beforeEach(async () => {
vi.resetModules();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('should allow requests from subdomain matching wildcard pattern', async () => {
const cors = await import('@fastify/cors');
app = Fastify();
await app.register(cors.default, {
origin: [/^https:\/\/[^\/]+\.preview\.example\.com$/],
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.get('/health', async () => ({ status: 'ok' }));
await app.ready();
const response = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://abc123.preview.example.com',
'access-control-request-method': 'GET',
},
});
expect(response.headers['access-control-allow-origin']).toBe(
'https://abc123.preview.example.com',
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
});
it('should reject requests from non-matching origin', async () => {
const cors = await import('@fastify/cors');
app = Fastify();
await app.register(cors.default, {
origin: [/^https:\/\/[^\/]+\.preview\.example\.com$/],
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.get('/health', async () => ({ status: 'ok' }));
await app.ready();
const response = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://evil.com',
'access-control-request-method': 'GET',
},
});
expect(response.headers['access-control-allow-origin']).toBeUndefined();
});
it('should allow multiple wildcard patterns', async () => {
const cors = await import('@fastify/cors');
app = Fastify();
await app.register(cors.default, {
origin: [
/^https:\/\/[^\/]+\.preview\.example\.com$/,
/^https:\/\/[^\/]+\.staging\.example\.com$/,
],
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.get('/health', async () => ({ status: 'ok' }));
await app.ready();
// Test first pattern
const response1 = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://feature-123.preview.example.com',
'access-control-request-method': 'GET',
},
});
expect(response1.headers['access-control-allow-origin']).toBe(
'https://feature-123.preview.example.com',
);
// Test second pattern
const response2 = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://test.staging.example.com',
'access-control-request-method': 'GET',
},
});
expect(response2.headers['access-control-allow-origin']).toBe(
'https://test.staging.example.com',
);
});
it('should work with mixed string and RegExp origins', async () => {
const cors = await import('@fastify/cors');
app = Fastify();
await app.register(cors.default, {
origin: ['https://app.example.com', /^https:\/\/[^\/]+\.preview\.example\.com$/],
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.get('/health', async () => ({ status: 'ok' }));
await app.ready();
// Test string origin
const response1 = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://app.example.com',
'access-control-request-method': 'GET',
},
});
expect(response1.headers['access-control-allow-origin']).toBe(
'https://app.example.com',
);
// Test RegExp origin
const response2 = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: {
origin: 'https://pr-456.preview.example.com',
'access-control-request-method': 'GET',
},
});
expect(response2.headers['access-control-allow-origin']).toBe(
'https://pr-456.preview.example.com',
);
});
});