-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <sejohnson@torchcloudconsulting.com>
- Loading branch information
1 parent
75f6cd8
commit f30352f
Showing
5 changed files
with
183 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
9 changes: 9 additions & 0 deletions
9
packages/kit/test/apps/dev-only/src/routes/headers/invalid/+server.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} |