Skip to content

Commit 4a2dc70

Browse files
authored
Merge pull request #119 from jonasyr/100-typesecurity-security-headers-missing-on-html-error-responses
fix(security): add strict security headers to HTML error responses
2 parents 3dfb809 + 8484e4c commit 4a2dc70

5 files changed

Lines changed: 529 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest';
2+
import express, { Express, Request, Response } from 'express';
3+
import helmet from 'helmet';
4+
import request from 'supertest';
5+
import { HTTP_STATUS } from '@gitray/shared-types';
6+
7+
// Mock the logger and metrics services
8+
vi.mock('../../src/services/logger', () => ({
9+
__esModule: true,
10+
default: {
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
http: vi.fn(),
16+
verbose: vi.fn(),
17+
silly: vi.fn(),
18+
},
19+
getLogger: vi.fn(() => ({
20+
info: vi.fn(),
21+
warn: vi.fn(),
22+
error: vi.fn(),
23+
debug: vi.fn(),
24+
http: vi.fn(),
25+
verbose: vi.fn(),
26+
silly: vi.fn(),
27+
})),
28+
}));
29+
30+
vi.mock('../../src/services/metrics', () => ({
31+
recordDetailedError: vi.fn(),
32+
updateServiceHealthScore: vi.fn(),
33+
getUserType: vi.fn(() => 'anonymous'),
34+
recordFeatureUsage: vi.fn(),
35+
}));
36+
37+
describe('Security Headers Integration Tests', () => {
38+
let app: Express;
39+
40+
beforeAll(async () => {
41+
// Create a test Express app that mimics the real application structure
42+
app = express();
43+
44+
// Apply Helmet middleware (like the real app)
45+
app.use(helmet());
46+
47+
// Add a test route that works
48+
app.get('/api/test', (req: Request, res: Response) => {
49+
res.json({ message: 'success' });
50+
});
51+
52+
// Import and apply the actual 404 handler from index.ts
53+
// We inline it here to match the implementation
54+
app.use((req: Request, res: Response) => {
55+
// Set strict security headers for error responses (defense-in-depth)
56+
res.setHeader(
57+
'Content-Security-Policy',
58+
"default-src 'none'; script-src 'none'; style-src 'none'; img-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
59+
);
60+
res.setHeader('X-Content-Type-Options', 'nosniff');
61+
res.setHeader('X-Frame-Options', 'DENY');
62+
res.setHeader('Content-Disposition', 'inline');
63+
64+
res.status(HTTP_STATUS.NOT_FOUND).json({
65+
error: 'Not Found',
66+
code: 'NOT_FOUND',
67+
});
68+
});
69+
70+
// Import and apply the actual error handler
71+
const errorHandlerModule = await import(
72+
'../../src/middlewares/errorHandler'
73+
);
74+
app.use(errorHandlerModule.default);
75+
});
76+
77+
afterAll(() => {
78+
vi.restoreAllMocks();
79+
});
80+
81+
describe('404 Handler Security Headers', () => {
82+
test('should include strict CSP on 404 responses', async () => {
83+
const response = await request(app).get('/nonexistent-route');
84+
85+
expect(response.status).toBe(404);
86+
expect(response.headers['content-security-policy']).toBeDefined();
87+
expect(response.headers['content-security-policy']).toContain(
88+
"default-src 'none'"
89+
);
90+
expect(response.headers['content-security-policy']).toContain(
91+
"script-src 'none'"
92+
);
93+
expect(response.headers['content-security-policy']).toContain(
94+
"frame-ancestors 'none'"
95+
);
96+
});
97+
98+
test('should include X-Content-Type-Options: nosniff on 404', async () => {
99+
const response = await request(app).get('/another-nonexistent');
100+
101+
expect(response.status).toBe(404);
102+
expect(response.headers['x-content-type-options']).toBe('nosniff');
103+
});
104+
105+
test('should include X-Frame-Options: DENY on 404', async () => {
106+
const response = await request(app).get('/yet-another-404');
107+
108+
expect(response.status).toBe(404);
109+
expect(response.headers['x-frame-options']).toBe('DENY');
110+
});
111+
112+
test('should include Content-Disposition: inline on 404', async () => {
113+
const response = await request(app).get('/missing-page');
114+
115+
expect(response.status).toBe(404);
116+
expect(response.headers['content-disposition']).toBe('inline');
117+
});
118+
119+
test('should return JSON response with 404', async () => {
120+
const response = await request(app).get('/test/404');
121+
122+
expect(response.status).toBe(404);
123+
expect(response.headers['content-type']).toMatch(/application\/json/);
124+
expect(response.body).toEqual({
125+
error: 'Not Found',
126+
code: 'NOT_FOUND',
127+
});
128+
});
129+
});
130+
131+
describe('Complete Security Header Suite', () => {
132+
test('should have all required security headers on error responses', async () => {
133+
const response = await request(app).get('/does-not-exist');
134+
135+
expect(response.status).toBe(404);
136+
137+
// Verify all 4 required headers are present
138+
expect(response.headers['content-security-policy']).toBeDefined();
139+
expect(response.headers['x-content-type-options']).toBeDefined();
140+
expect(response.headers['x-frame-options']).toBeDefined();
141+
expect(response.headers['content-disposition']).toBeDefined();
142+
143+
// Verify CSP is strict (blocks all resources)
144+
const csp = response.headers['content-security-policy'];
145+
expect(csp).toContain("default-src 'none'");
146+
expect(csp).toContain("script-src 'none'");
147+
expect(csp).toContain("style-src 'none'");
148+
expect(csp).toContain("img-src 'none'");
149+
expect(csp).toContain("object-src 'none'");
150+
expect(csp).toContain("base-uri 'none'");
151+
expect(csp).toContain("form-action 'none'");
152+
expect(csp).toContain("frame-ancestors 'none'");
153+
});
154+
});
155+
156+
describe('XSS Payload Handling with Security Headers', () => {
157+
test('should return safe JSON with security headers for XSS payloads', async () => {
158+
const xssPayloads = [
159+
'/%3Cscript%3Ealert(1)%3C/script%3E',
160+
'/%3Csvg%2Fonload%3Dalert(1)%3E',
161+
'/%22%3E%3Cimg%20src=x%3E',
162+
];
163+
164+
for (const payload of xssPayloads) {
165+
const response = await request(app).get(payload);
166+
167+
expect(response.status).toBe(404);
168+
expect(response.body).toEqual({
169+
error: 'Not Found',
170+
code: 'NOT_FOUND',
171+
});
172+
173+
// Verify security headers are present
174+
expect(response.headers['content-security-policy']).toContain(
175+
"default-src 'none'"
176+
);
177+
expect(response.headers['x-content-type-options']).toBe('nosniff');
178+
expect(response.headers['x-frame-options']).toBe('DENY');
179+
expect(response.headers['content-disposition']).toBe('inline');
180+
181+
// Verify no payload reflection
182+
expect(JSON.stringify(response.body)).not.toContain('script');
183+
expect(JSON.stringify(response.body)).not.toContain('alert');
184+
expect(JSON.stringify(response.body)).not.toContain('svg');
185+
}
186+
});
187+
});
188+
189+
describe('Normal Routes', () => {
190+
test('should not interfere with successful responses', async () => {
191+
const response = await request(app).get('/api/test');
192+
193+
expect(response.status).toBe(200);
194+
expect(response.body).toEqual({ message: 'success' });
195+
196+
// Normal routes should have Helmet's default CSP, not the strict error CSP
197+
const csp = response.headers['content-security-policy'];
198+
if (csp) {
199+
// Should NOT have the strict "default-src 'none'" from error handler
200+
expect(csp).not.toContain("default-src 'none'");
201+
}
202+
});
203+
});
204+
});

0 commit comments

Comments
 (0)