Skip to content

Commit f30352f

Browse files
feat: validate values for cache-control and content-type headers in dev mode (#13114)
* Add header validator * Validate headers * Test route for the invalid headers * changeset * Capture all IANA top level content types * chore: Slight improvements before merge * ugh lint --------- Co-authored-by: S. Elliott Johnson <[email protected]>
1 parent 75f6cd8 commit f30352f

File tree

5 files changed

+183
-0
lines changed

5 files changed

+183
-0
lines changed

.changeset/rich-pants-beam.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: validate values for `cache-control` and `content-type` headers in dev mode

packages/kit/src/runtime/server/respond.js

+6
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js';
3333
import { get_public_env } from './env_module.js';
3434
import { load_page_nodes } from './page/load_page_nodes.js';
3535
import { get_page_config } from '../../utils/route_config.js';
36+
import { validateHeaders } from './validate-headers.js';
3637

3738
/* global __SVELTEKIT_ADAPTER_NAME__ */
39+
/* global __SVELTEKIT_DEV__ */
3840

3941
/** @type {import('types').RequiredResolveOptions['transformPageChunk']} */
4042
const default_transform = ({ html }) => html;
@@ -186,6 +188,10 @@ export async function respond(request, options, manifest, state) {
186188
request,
187189
route: { id: route?.id ?? null },
188190
setHeaders: (new_headers) => {
191+
if (__SVELTEKIT_DEV__) {
192+
validateHeaders(new_headers);
193+
}
194+
189195
for (const key in new_headers) {
190196
const lower = key.toLowerCase();
191197
const value = new_headers[key];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/** @type {Set<string>} */
2+
const VALID_CACHE_CONTROL_DIRECTIVES = new Set([
3+
'max-age',
4+
'public',
5+
'private',
6+
'no-cache',
7+
'no-store',
8+
'must-revalidate',
9+
'proxy-revalidate',
10+
's-maxage',
11+
'immutable',
12+
'stale-while-revalidate',
13+
'stale-if-error',
14+
'no-transform',
15+
'only-if-cached',
16+
'max-stale',
17+
'min-fresh'
18+
]);
19+
20+
const CONTENT_TYPE_PATTERN =
21+
/^(application|audio|example|font|haptics|image|message|model|multipart|text|video|x-[a-z]+)\/[-+.\w]+$/i;
22+
23+
/** @type {Record<string, (value: string) => void>} */
24+
const HEADER_VALIDATORS = {
25+
'cache-control': (value) => {
26+
const error_suffix = `(While parsing "${value}".)`;
27+
const parts = value.split(',').map((part) => part.trim());
28+
if (parts.some((part) => !part)) {
29+
throw new Error(`\`cache-control\` header contains empty directives. ${error_suffix}`);
30+
}
31+
32+
const directives = parts.map((part) => part.split('=')[0].toLowerCase());
33+
const invalid = directives.find((directive) => !VALID_CACHE_CONTROL_DIRECTIVES.has(directive));
34+
if (invalid) {
35+
throw new Error(
36+
`Invalid cache-control directive "${invalid}". Did you mean one of: ${[...VALID_CACHE_CONTROL_DIRECTIVES].join(', ')}? ${error_suffix}`
37+
);
38+
}
39+
},
40+
41+
'content-type': (value) => {
42+
const type = value.split(';')[0].trim();
43+
const error_suffix = `(While parsing "${value}".)`;
44+
if (!CONTENT_TYPE_PATTERN.test(type)) {
45+
throw new Error(`Invalid content-type value "${type}". ${error_suffix}`);
46+
}
47+
}
48+
};
49+
50+
/**
51+
* @param {Record<string, string>} headers
52+
*/
53+
export function validateHeaders(headers) {
54+
for (const [key, value] of Object.entries(headers)) {
55+
const validator = HEADER_VALIDATORS[key.toLowerCase()];
56+
try {
57+
validator?.(value);
58+
} catch (error) {
59+
if (error instanceof Error) {
60+
console.warn(`[SvelteKit] ${error.message}`);
61+
}
62+
}
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, test, expect, beforeEach, vi } from 'vitest';
2+
import { validateHeaders } from './validate-headers.js';
3+
4+
describe('validateHeaders', () => {
5+
const console_warn_spy = vi.spyOn(console, 'warn');
6+
7+
beforeEach(() => {
8+
vi.resetAllMocks();
9+
});
10+
11+
describe('cache-control header', () => {
12+
test('accepts valid directives', () => {
13+
validateHeaders({ 'cache-control': 'public, max-age=3600' });
14+
expect(console_warn_spy).not.toHaveBeenCalled();
15+
});
16+
17+
test('rejects invalid directives', () => {
18+
validateHeaders({ 'cache-control': 'public, maxage=3600' });
19+
expect(console_warn_spy).toHaveBeenCalledWith(
20+
expect.stringContaining('Invalid cache-control directive "maxage"')
21+
);
22+
});
23+
24+
test('rejects empty directives', () => {
25+
validateHeaders({ 'cache-control': 'public,, max-age=3600' });
26+
expect(console_warn_spy).toHaveBeenCalledWith(
27+
expect.stringContaining('`cache-control` header contains empty directives')
28+
);
29+
30+
validateHeaders({ 'cache-control': 'public, , max-age=3600' });
31+
expect(console_warn_spy).toHaveBeenCalledWith(
32+
expect.stringContaining('`cache-control` header contains empty directives')
33+
);
34+
});
35+
36+
test('accepts multiple cache-control values', () => {
37+
validateHeaders({ 'cache-control': 'max-age=3600, s-maxage=7200' });
38+
expect(console_warn_spy).not.toHaveBeenCalled();
39+
});
40+
});
41+
42+
describe('content-type header', () => {
43+
test('accepts standard content types', () => {
44+
validateHeaders({ 'content-type': 'application/json' });
45+
expect(console_warn_spy).not.toHaveBeenCalled();
46+
});
47+
48+
test('accepts content types with parameters', () => {
49+
validateHeaders({ 'content-type': 'text/html; charset=utf-8' });
50+
expect(console_warn_spy).not.toHaveBeenCalled();
51+
52+
validateHeaders({ 'content-type': 'application/javascript; charset=utf-8' });
53+
expect(console_warn_spy).not.toHaveBeenCalled();
54+
});
55+
56+
test('accepts vendor-specific content types', () => {
57+
validateHeaders({ 'content-type': 'x-custom/whatever' });
58+
expect(console_warn_spy).not.toHaveBeenCalled();
59+
});
60+
61+
test('rejects malformed content types', () => {
62+
validateHeaders({ 'content-type': 'invalid-content-type' });
63+
expect(console_warn_spy).toHaveBeenCalledWith(
64+
expect.stringContaining('Invalid content-type value "invalid-content-type"')
65+
);
66+
});
67+
68+
test('rejects invalid content type categories', () => {
69+
validateHeaders({ 'content-type': 'invalid/type; invalid=param' });
70+
expect(console_warn_spy).toHaveBeenCalledWith(
71+
expect.stringContaining('Invalid content-type value "invalid/type"')
72+
);
73+
74+
validateHeaders({ 'content-type': 'bad/type; charset=utf-8' });
75+
expect(console_warn_spy).toHaveBeenCalledWith(
76+
expect.stringContaining('Invalid content-type value "bad/type"')
77+
);
78+
});
79+
80+
test('handles case-insensitive content-types', () => {
81+
validateHeaders({ 'content-type': 'TEXT/HTML; charset=utf-8' });
82+
expect(console_warn_spy).not.toHaveBeenCalled();
83+
});
84+
});
85+
86+
test('allows unknown headers', () => {
87+
validateHeaders({ 'x-custom-header': 'some-value' });
88+
expect(console_warn_spy).not.toHaveBeenCalled();
89+
});
90+
91+
test('handles multiple headers simultaneously', () => {
92+
validateHeaders({
93+
'cache-control': 'max-age=3600',
94+
'content-type': 'text/html',
95+
'x-custom': 'value'
96+
});
97+
expect(console_warn_spy).not.toHaveBeenCalled();
98+
});
99+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @type {import("@sveltejs/kit").RequestHandler} */
2+
export function GET({ setHeaders }) {
3+
setHeaders({
4+
'cache-control': 'totally-invalid',
5+
'content-type': 'not-a-real-type'
6+
});
7+
8+
return new Response('Testing invalid headers');
9+
}

0 commit comments

Comments
 (0)