Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: validate values for cache-control and content-type headers in dev mode #13114

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-pants-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: validate values for `cache-control` and `content-type` headers in dev mode
6 changes: 6 additions & 0 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js';
import { get_public_env } from './env_module.js';
import { load_page_nodes } from './page/load_page_nodes.js';
import { get_page_config } from '../../utils/route_config.js';
import { validateHeaders } from './validate-headers.js';

/* global __SVELTEKIT_ADAPTER_NAME__ */
/* global __SVELTEKIT_DEV__ */

/** @type {import('types').RequiredResolveOptions['transformPageChunk']} */
const default_transform = ({ html }) => html;
Expand Down Expand Up @@ -186,6 +188,10 @@ export async function respond(request, options, manifest, state) {
request,
route: { id: route?.id ?? null },
setHeaders: (new_headers) => {
if (__SVELTEKIT_DEV__) {
validateHeaders(new_headers);
}

for (const key in new_headers) {
const lower = key.toLowerCase();
const value = new_headers[key];
Expand Down
64 changes: 64 additions & 0 deletions packages/kit/src/runtime/server/validate-headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/** @type {Set<string>} */
const VALID_CACHE_CONTROL_DIRECTIVES = new Set([
'max-age',
'public',
'private',
'no-cache',
'no-store',
'must-revalidate',
'proxy-revalidate',
's-maxage',
'immutable',
'stale-while-revalidate',
'stale-if-error',
'no-transform',
'only-if-cached',
'max-stale',
'min-fresh'
]);

const CONTENT_TYPE_PATTERN =
/^(application|audio|example|font|haptics|image|message|model|multipart|text|video|x-[a-z]+)\/[-+.\w]+$/i;

/** @type {Record<string, (value: string) => void>} */
const HEADER_VALIDATORS = {
'cache-control': (value) => {
const error_suffix = `(While parsing "${value}".)`;
const parts = value.split(',').map((part) => part.trim());
if (parts.some((part) => !part)) {
throw new Error(`\`cache-control\` header contains empty directives. ${error_suffix}`);
}

const directives = parts.map((part) => part.split('=')[0].toLowerCase());
const invalid = directives.find((directive) => !VALID_CACHE_CONTROL_DIRECTIVES.has(directive));
if (invalid) {
throw new Error(
`Invalid cache-control directive "${invalid}". Did you mean one of: ${[...VALID_CACHE_CONTROL_DIRECTIVES].join(', ')}? ${error_suffix}`
);
}
},

'content-type': (value) => {
const type = value.split(';')[0].trim();
const error_suffix = `(While parsing "${value}".)`;
if (!CONTENT_TYPE_PATTERN.test(type)) {
throw new Error(`Invalid content-type value "${type}". ${error_suffix}`);
}
}
};

/**
* @param {Record<string, string>} headers
*/
export function validateHeaders(headers) {
for (const [key, value] of Object.entries(headers)) {
const validator = HEADER_VALIDATORS[key.toLowerCase()];
try {
validator?.(value);
} catch (error) {
if (error instanceof Error) {
console.warn(`[SvelteKit] ${error.message}`);
}
}
}
}
99 changes: 99 additions & 0 deletions packages/kit/src/runtime/server/validate-headers.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { validateHeaders } from './validate-headers.js';

describe('validateHeaders', () => {
const console_warn_spy = vi.spyOn(console, 'warn');

beforeEach(() => {
vi.resetAllMocks();
});

describe('cache-control header', () => {
test('accepts valid directives', () => {
validateHeaders({ 'cache-control': 'public, max-age=3600' });
expect(console_warn_spy).not.toHaveBeenCalled();
});

test('rejects invalid directives', () => {
validateHeaders({ 'cache-control': 'public, maxage=3600' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('Invalid cache-control directive "maxage"')
);
});

test('rejects empty directives', () => {
validateHeaders({ 'cache-control': 'public,, max-age=3600' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('`cache-control` header contains empty directives')
);

validateHeaders({ 'cache-control': 'public, , max-age=3600' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('`cache-control` header contains empty directives')
);
});

test('accepts multiple cache-control values', () => {
validateHeaders({ 'cache-control': 'max-age=3600, s-maxage=7200' });
expect(console_warn_spy).not.toHaveBeenCalled();
});
});

describe('content-type header', () => {
test('accepts standard content types', () => {
validateHeaders({ 'content-type': 'application/json' });
expect(console_warn_spy).not.toHaveBeenCalled();
});

test('accepts content types with parameters', () => {
validateHeaders({ 'content-type': 'text/html; charset=utf-8' });
expect(console_warn_spy).not.toHaveBeenCalled();

validateHeaders({ 'content-type': 'application/javascript; charset=utf-8' });
expect(console_warn_spy).not.toHaveBeenCalled();
});

test('accepts vendor-specific content types', () => {
validateHeaders({ 'content-type': 'x-custom/whatever' });
expect(console_warn_spy).not.toHaveBeenCalled();
});

test('rejects malformed content types', () => {
validateHeaders({ 'content-type': 'invalid-content-type' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('Invalid content-type value "invalid-content-type"')
);
});

test('rejects invalid content type categories', () => {
validateHeaders({ 'content-type': 'invalid/type; invalid=param' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('Invalid content-type value "invalid/type"')
);

validateHeaders({ 'content-type': 'bad/type; charset=utf-8' });
expect(console_warn_spy).toHaveBeenCalledWith(
expect.stringContaining('Invalid content-type value "bad/type"')
);
});

test('handles case-insensitive content-types', () => {
validateHeaders({ 'content-type': 'TEXT/HTML; charset=utf-8' });
expect(console_warn_spy).not.toHaveBeenCalled();
});
});

test('allows unknown headers', () => {
validateHeaders({ 'x-custom-header': 'some-value' });
expect(console_warn_spy).not.toHaveBeenCalled();
});

test('handles multiple headers simultaneously', () => {
validateHeaders({
'cache-control': 'max-age=3600',
'content-type': 'text/html',
'x-custom': 'value'
});
expect(console_warn_spy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import("@sveltejs/kit").RequestHandler} */
export function GET({ setHeaders }) {
setHeaders({
'cache-control': 'totally-invalid',
'content-type': 'not-a-real-type'
});

return new Response('Testing invalid headers');
}
Loading