From 64a3f908316048f826dd0515c56b670b32a15282 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 7 Jul 2020 12:53:36 +0200 Subject: [PATCH] feat: Validate Accept* headers while parsing --- src/util/AcceptParser.ts | 111 ++++++++++++++++++++++++---- test/unit/util/AcceptParser.test.ts | 59 ++++++++++++++- 2 files changed, 154 insertions(+), 16 deletions(-) diff --git a/src/util/AcceptParser.ts b/src/util/AcceptParser.ts index b76fef840..d8e854f2c 100644 --- a/src/util/AcceptParser.ts +++ b/src/util/AcceptParser.ts @@ -1,3 +1,5 @@ +import { UnsupportedHttpError } from './errors/UnsupportedHttpError'; + // BNF based on https://tools.ietf.org/html/rfc7231 // // Accept = #( media-range [ accept-params ] ) @@ -83,6 +85,9 @@ export interface AcceptEncoding extends AcceptHeader { } */ export interface AcceptLanguage extends AcceptHeader { } +// REUSED REGEXES +const token = /^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$/u; + // HELPER FUNCTIONS /** * Replaces all double quoted strings in the input string with `"0"`, `"1"`, etc. @@ -94,6 +99,10 @@ const transformQuotedStrings = (input: string): { result: string; replacements: let idx = 0; const replacements: { [id: string]: string } = {}; const result = input.replace(/"(?:[^"\\]|\\.)*"/gu, (match): string => { + // Not all characters allowed in quoted strings, see BNF above + if (!/^"(?:[\t !\u0023-\u005b\u005d-\u007e\u0080-\u00ff]|(?:\\[\t\u0020-\u007e\u0080-\u00ff]))*"$/u.test(match)) { + throw new UnsupportedHttpError(`Invalid quoted string in Accept header: ${match}. Check which characters are allowed`); + } const replacement = `"${idx}"`; replacements[replacement] = match; idx += 1; @@ -102,40 +111,79 @@ const transformQuotedStrings = (input: string): { result: string; replacements: return { result, replacements }; }; +/** + * Splits the input string on commas, trims all parts and filters out empty ones. + * + * @param input - Input header string. + */ const splitAndClean = (input: string): string[] => input.split(',') .map((part): string => part.trim()) .filter((part): boolean => part.length > 0); +/** + * Checks if the input string matches the qvalue regex. + * + * @param qvalue - Input qvalue string (so "q=...."). + * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid syntax. + */ +const testQValue = (qvalue: string): void => { + if (!/^q=(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) { + throw new UnsupportedHttpError(`Invalid q value: ${qvalue} does not match ("q=" ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )).`); + } +}; + /** * Parses a single media range with corresponding parameters from an Accept header. * For every parameter value that is a double quoted string, * we check if it is a key in the replacements map. * If yes the value from the map gets inserted instead. + * * @param part - A string corresponding to a media range and its corresponding parameters. * @param replacements - The double quoted strings that need to be replaced. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid type, qvalue or parameter syntax. + * * @returns {@link Accept} object corresponding to the header string. */ const parseAcceptPart = (part: string, replacements: { [id: string]: string }): Accept => { const [ range, ...parameters ] = part.split(';').map((param): string => param.trim()); + + // No reason to test differently for * since we don't check if the type exists + const [ type, subtype ] = range.split('/'); + if (!type || !subtype || !token.test(type) || !token.test(subtype)) { + throw new Error(`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`); + } + let weight = 1; const mediaTypeParams: { [key: string]: string } = {}; const extensionParams: { [key: string]: string } = {}; let map = mediaTypeParams; parameters.forEach((param): void => { const [ name, value ] = param.split('='); - let actualValue = value; - - if (value && value.length > 0 && value.startsWith('"') && replacements[value]) { - actualValue = replacements[value]; - } if (name === 'q') { // Extension parameters appear after the q value map = extensionParams; - weight = parseFloat(actualValue); + testQValue(param); + weight = parseFloat(value); } else { + // Test replaced string for easier check + // parameter = token "=" ( token / quoted-string ) + // second part is optional for extension parameters + if (!token.test(name) || !((map === extensionParams && !value) || (value && (/^"\d+"$/u.test(value) || token.test(value))))) { + throw new UnsupportedHttpError(`Invalid Accept parameter: ${param} does not match (token "=" ( token / quoted-string )). ` + + 'Second part optional for extension parameters.'); + } + + let actualValue = value; + if (value && value.length > 0 && value.startsWith('"') && replacements[value]) { + actualValue = replacements[value]; + } + // Value is optional for extension parameters map[name] = actualValue || ''; } @@ -155,6 +203,9 @@ const parseAcceptPart = (part: string, replacements: { [id: string]: string }): * Parses an Accept-* header where each part is only a value and a weight, so roughly /.*(q=.*)?/ separated by commas. * @param input - Input header string. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid qvalue syntax. + * * @returns An array of ranges and weights. */ const parseNoParameters = (input: string): { range: string; weight: number }[] => { @@ -164,6 +215,7 @@ const parseNoParameters = (input: string): { range: string; weight: number }[] = const [ range, qvalue ] = part.split(';').map((param): string => param.trim()); const result = { range, weight: 1 }; if (qvalue) { + testQValue(qvalue); result.weight = parseFloat(qvalue.split('=')[1]); } return result; @@ -174,10 +226,12 @@ const parseNoParameters = (input: string): { range: string; weight: number }[] = /** * Parses an Accept header string. - * No validation is done so this assumes a valid input string. * * @param input - The Accept header string. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid header syntax. + * * @returns An array of {@link Accept} objects, sorted by weight. */ export const parseAccept = (input: string): Accept[] => { @@ -190,30 +244,61 @@ export const parseAccept = (input: string): Accept[] => { /** * Parses an Accept-Charset header string. - * No validation is done so this assumes a valid input string. * * @param input - The Accept-Charset header string. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid header syntax. + * * @returns An array of {@link AcceptCharset} objects, sorted by weight. */ -export const parseAcceptCharset = (input: string): AcceptCharset[] => parseNoParameters(input); +export const parseAcceptCharset = (input: string): AcceptCharset[] => { + const results = parseNoParameters(input); + results.forEach((result): void => { + if (!token.test(result.range)) { + throw new UnsupportedHttpError(`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`); + } + }); + return results; +}; /** * Parses an Accept-Encoding header string. - * No validation is done so this assumes a valid input string. * * @param input - The Accept-Encoding header string. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid header syntax. + * * @returns An array of {@link AcceptEncoding} objects, sorted by weight. */ -export const parseAcceptEncoding = (input: string): AcceptEncoding[] => parseNoParameters(input); +export const parseAcceptEncoding = (input: string): AcceptEncoding[] => { + const results = parseNoParameters(input); + results.forEach((result): void => { + if (!token.test(result.range)) { + throw new UnsupportedHttpError(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`); + } + }); + return results; +}; /** * Parses an Accept-Language header string. - * No validation is done so this assumes a valid input string. * * @param input - The Accept-Language header string. * + * @throws {@link UnsupportedHttpError} + * Thrown on invalid header syntax. + * * @returns An array of {@link AcceptLanguage} objects, sorted by weight. */ -export const parseAcceptLanguage = (input: string): AcceptLanguage[] => parseNoParameters(input); +export const parseAcceptLanguage = (input: string): AcceptLanguage[] => { + const results = parseNoParameters(input); + results.forEach((result): void => { + // (1*8ALPHA *("-" 1*8alphanum)) / "*" + if (result.range !== '*' && !/^[a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*$/u.test(result.range)) { + throw new UnsupportedHttpError(`Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`); + } + }); + return results; +}; diff --git a/test/unit/util/AcceptParser.test.ts b/test/unit/util/AcceptParser.test.ts index fdfbac4ed..25a6c1d67 100644 --- a/test/unit/util/AcceptParser.test.ts +++ b/test/unit/util/AcceptParser.test.ts @@ -1,4 +1,9 @@ -import { parseAccept, parseAcceptCharset, parseAcceptLanguage } from '../../../src/util/AcceptParser'; +import { + parseAccept, + parseAcceptCharset, + parseAcceptEncoding, + parseAcceptLanguage, +} from '../../../src/util/AcceptParser'; describe('AcceptParser', (): void => { describe('parseAccept function', (): void => { @@ -20,7 +25,7 @@ describe('AcceptParser', (): void => { }); it('parses complex Accept headers.', async(): Promise => { - expect(parseAccept('text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4,text/x-dvi; q=.8; mxb=100000; mxt')).toEqual([ + expect(parseAccept('text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4,text/x-dvi; q=0.8; mxb=100000; mxt')).toEqual([ { range: 'text/html', weight: 1, parameters: { mediaType: { level: '1' }, extension: {}}}, { range: 'text/x-dvi', weight: 0.8, parameters: { mediaType: {}, extension: { mxb: '100000', mxt: '' }}}, { range: 'text/html', weight: 0.7, parameters: { mediaType: {}, extension: {}}}, @@ -33,6 +38,33 @@ describe('AcceptParser', (): void => { { range: 'audio/basic', weight: 0.5, parameters: { mediaType: { param1: '"val"' }, extension: { param2: '"\\\\\\"valid"' }}}, ]); }); + + it('rejects Accept Headers with invalid types.', async(): Promise => { + expect((): any => parseAccept('*')).toThrow('Invalid Accept range:'); + expect((): any => parseAccept('"bad"/text')).toThrow('Invalid Accept range:'); + expect((): any => parseAccept('*/\\bad')).toThrow('Invalid Accept range:'); + expect((): any => parseAccept('*/*')).not.toThrow('Invalid Accept range:'); + }); + + it('rejects Accept Headers with invalid q values.', async(): Promise => { + expect((): any => parseAccept('a/b; q=text')).toThrow('Invalid q value:'); + expect((): any => parseAccept('a/b; q=0.1234')).toThrow('Invalid q value:'); + expect((): any => parseAccept('a/b; q=1.1')).toThrow('Invalid q value:'); + expect((): any => parseAccept('a/b; q=1.000')).not.toThrow(); + expect((): any => parseAccept('a/b; q=0.123')).not.toThrow(); + }); + + it('rejects Accept Headers with invalid parameters.', async(): Promise => { + expect((): any => parseAccept('a/b; a')).toThrow('Invalid Accept parameter:'); + expect((): any => parseAccept('a/b; a=\\')).toThrow('Invalid Accept parameter:'); + expect((): any => parseAccept('a/b; q=1 ; a=\\')).toThrow('Invalid Accept parameter:'); + expect((): any => parseAccept('a/b; q=1 ; a')).not.toThrow('Invalid Accept parameter:'); + }); + + it('rejects Accept Headers with quoted parameters.', async(): Promise => { + expect((): any => parseAccept('a/b; a="\\""')).not.toThrow(); + expect((): any => parseAccept('a/b; a="\\\u007F"')).toThrow('Invalid quoted string in Accept header:'); + }); }); describe('parseCharset function', (): void => { @@ -42,6 +74,11 @@ describe('AcceptParser', (): void => { { range: 'unicode-1-1', weight: 0.8 }, ]); }); + + it('rejects invalid Accept-Charset Headers.', async(): Promise => { + expect((): any => parseAcceptCharset('a/b')).toThrow('Invalid Accept-Charset range:'); + expect((): any => parseAcceptCharset('a; q=text')).toThrow('Invalid q value:'); + }); }); describe('parseEncoding function', (): void => { @@ -50,12 +87,17 @@ describe('AcceptParser', (): void => { }); it('parses Accept-Encoding headers.', async(): Promise => { - expect(parseAcceptCharset('gzip;q=1.0, identity; q=0.5, *;q=0')).toEqual([ + expect(parseAcceptEncoding('gzip;q=1.000, identity; q=0.5, *;q=0')).toEqual([ { range: 'gzip', weight: 1 }, { range: 'identity', weight: 0.5 }, { range: '*', weight: 0 }, ]); }); + + it('rejects invalid Accept-Encoding Headers.', async(): Promise => { + expect((): any => parseAcceptEncoding('a/b')).toThrow('Invalid Accept-Encoding range:'); + expect((): any => parseAcceptEncoding('a; q=text')).toThrow('Invalid q value:'); + }); }); describe('parseLanguage function', (): void => { @@ -66,5 +108,16 @@ describe('AcceptParser', (): void => { { range: 'en', weight: 0.7 }, ]); }); + + it('rejects invalid Accept-Language Headers.', async(): Promise => { + expect((): any => parseAcceptLanguage('a/b')).toThrow('Invalid Accept-Language range:'); + expect((): any => parseAcceptLanguage('05-a')).toThrow('Invalid Accept-Language range:'); + expect((): any => parseAcceptLanguage('a--05')).toThrow('Invalid Accept-Language range:'); + expect((): any => parseAcceptLanguage('a-"a"')).toThrow('Invalid Accept-Language range:'); + expect((): any => parseAcceptLanguage('a-05')).not.toThrow('Invalid Accept-Language range:'); + expect((): any => parseAcceptLanguage('a-b-c-d')).not.toThrow('Invalid Accept-Language range:'); + + expect((): any => parseAcceptLanguage('a; q=text')).toThrow('Invalid q value:'); + }); }); });