mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { FastifyInstance } from 'fastify';
|
|
import helmet from '@fastify/helmet';
|
|
import { sanitizeRequestUrlForLog } from '../src/server';
|
|
|
|
describe('Server Security Configuration', () => {
|
|
describe('request URL log sanitization', () => {
|
|
it('should redact sensitive query params without removing non-sensitive context', () => {
|
|
expect(
|
|
sanitizeRequestUrlForLog(
|
|
'/api/sync/ws?token=secret-jwt&clientId=B_AEh6&limit=10',
|
|
),
|
|
).toBe('/api/sync/ws?token=redacted&clientId=B_AEh6&limit=10');
|
|
});
|
|
|
|
it('should redact sensitive query params case-insensitively', () => {
|
|
expect(sanitizeRequestUrlForLog('/reset-password?resetPasswordToken=secret')).toBe(
|
|
'/reset-password?resetPasswordToken=redacted',
|
|
);
|
|
});
|
|
});
|
|
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
it('should escape < and > characters', () => {
|
|
const input = '<script>alert("xss")</script>';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('<script>alert("xss")</script>');
|
|
expect(escaped).not.toContain('<');
|
|
expect(escaped).not.toContain('>');
|
|
});
|
|
|
|
it('should escape ampersand', () => {
|
|
const input = 'Tom & Jerry';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('Tom & Jerry');
|
|
});
|
|
|
|
it('should escape double quotes', () => {
|
|
const input = 'He said "hello"';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('He said "hello"');
|
|
});
|
|
|
|
it('should escape single quotes', () => {
|
|
const input = "It's a test";
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('It'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(
|
|
'<div class="test" data-value='a & b'>content</div>',
|
|
);
|
|
});
|
|
|
|
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('" onmouseover="alert(1)"');
|
|
// The key protection is that " is escaped to "
|
|
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 "
|
|
expect(html).toContain('"');
|
|
});
|
|
|
|
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 < to prevent injection
|
|
expect(html).not.toContain('</script><script>');
|
|
expect(html).toContain('<'); // < 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',
|
|
);
|
|
});
|
|
});
|