mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-22 07:28:14 +00:00
Security fixes implemented:
1. CRITICAL: Fix missing await in email verification (pages.ts:139)
- verifyEmail() was called without await, causing race condition
- Response was sent before verification completed
2. CRITICAL: Fix XSS vulnerability in password reset page (pages.ts)
- Added safeJsonForScript() to properly escape tokens in JS context
- JSON.stringify alone doesn't escape </script> sequences
- Now escapes <, >, & as unicode (\u003c, \u003e, \u0026)
3. CRITICAL: Fix XSS in privacy HTML template (server.ts)
- Added escapeHtml() function for all template interpolations
- Prevents XSS if environment variables contain malicious content
4. HIGH: Enable Content Security Policy (server.ts)
- CSP was disabled (contentSecurityPolicy: false)
- Now enabled with strict directives:
- default-src 'self', object-src 'none', frame-ancestors 'none'
5. HIGH: Block wildcard CORS in production (config.ts)
- CORS_ORIGINS=* with credentials is a security vulnerability
- Now throws error in production, warns in development
6. HIGH: Add password reset flow (auth.ts, api.ts, email.ts, schema.prisma)
- Secure token generation with crypto.randomBytes(32)
- 1-hour expiry, one-time use tokens
- Revokes all sessions on password reset
- Prevents email enumeration (same response for all cases)
- Rate limited: 5 requests/15min for forgot-password
- Rate limited: 10 requests/15min for reset-password
Test coverage:
- 45 new tests across 4 test files
- Tests for XSS prevention, CORS blocking, CSP headers
- Tests for password reset flow (API and unit level)
- Fixed pre-existing flaky boundary test in retention-config.spec.ts
328 lines
9.8 KiB
TypeScript
328 lines
9.8 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, '&')
|
|
.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 JavaScript context', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
// Test JavaScript injection attempt - single quotes should be safe
|
|
// because safeJsonForScript wraps in double quotes
|
|
const maliciousToken = "';alert(1);//";
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: `/reset-password?token=${encodeURIComponent(maliciousToken)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const html = response.body;
|
|
|
|
// Token is wrapped in double quotes by JSON.stringify, so single quotes are safe
|
|
// The actual string content appears inside double quotes in the JS
|
|
expect(html).toContain('"');
|
|
// The raw attack string should not appear unquoted
|
|
expect(html).not.toMatch(/token:\s*'.*;alert/);
|
|
});
|
|
|
|
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;
|
|
|
|
// safeJsonForScript escapes < as \u003c to prevent </script> injection
|
|
expect(html).not.toContain('</script><script>');
|
|
expect(html).toContain('\\u003c'); // < escaped as unicode
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|