import type { IncomingHttpHeaders } from 'http'; import escapeStringRegexp from 'escape-string-regexp'; import { getLoggerFor } from '../logging/LogUtil'; import type { HttpResponse } from '../server/HttpResponse'; import { BadRequestHttpError } from './errors/BadRequestHttpError'; const logger = getLoggerFor('HeaderUtil'); // Map used as a simple cache in the helper function matchesAuthorizationScheme. const authSchemeRegexCache: Map = new Map(); // BNF based on https://tools.ietf.org/html/rfc7231 // // Accept = #( media-range [ accept-params ] ) // Accept-Charset = 1#( ( charset / "*" ) [ weight ] ) // Accept-Encoding = #( codings [ weight ] ) // Accept-Language = 1#( language-range [ weight ] ) // // Content-Type = media-type // media-type = type "/" subtype *( OWS ";" OWS parameter ) // // media-range = ( "*/*" // / ( type "/" "*" ) // / ( type "/" subtype ) // ) *( OWS ";" OWS parameter ) ; media type parameters // accept-params = weight *( accept-ext ) // accept-ext = OWS ";" OWS token [ "=" ( token / quoted-string ) ] ; extension parameters // // weight = OWS ";" OWS "q=" qvalue // qvalue = ( "0" [ "." 0*3DIGIT ] ) // / ( "1" [ "." 0*3("0") ] ) // // type = token // subtype = token // parameter = token "=" ( token / quoted-string ) // // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE // qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text // obs-text = %x80-FF // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) // // charset = token // // codings = content-coding / "identity" / "*" // content-coding = token // // language-range = (1*8ALPHA *("-" 1*8alphanum)) / "*" // alphanum = ALPHA / DIGIT // // Delimiters are chosen from the set of US-ASCII visual characters // not allowed in a token (DQUOTE and "(),/:;<=>?@[\]{}"). // token = 1*tchar // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" // / DIGIT / ALPHA // ; any VCHAR, except delimiters // // INTERFACES /** * General interface for all Accept* headers. */ export interface AcceptHeader { /** Requested range. Can be a specific value or `*`, matching all. */ range: string; /** Weight of the preference [0, 1]. */ weight: number; } /** * Contents of an HTTP Accept header. * Range is type/subtype. Both can be `*`. */ export interface Accept extends AcceptHeader { parameters: { /** Media type parameters. These are the parameters that came before the q value. */ mediaType: Record; /** * Extension parameters. These are the parameters that came after the q value. * Value will be an empty string if there was none. */ extension: Record; }; } /** * Contents of an HTTP Accept-Charset header. */ export interface AcceptCharset extends AcceptHeader { } /** * Contents of an HTTP Accept-Encoding header. */ export interface AcceptEncoding extends AcceptHeader { } /** * Contents of an HTTP Accept-Language header. */ export interface AcceptLanguage extends AcceptHeader { } /** * Contents of an HTTP Accept-Datetime header. */ export interface AcceptDatetime extends AcceptHeader { } /** * Contents of a HTTP Content-Type Header. * Optional parameters Record is included. */ export class ContentType { public constructor(public value: string, public parameters: Record = {}) {} /** * Serialize this ContentType object to a ContentType header appropriate value string. * @returns The value string, including parameters, if present. */ public toHeaderValueString(): string { return Object.entries(this.parameters) .sort((entry1, entry2): number => entry1[0].localeCompare(entry2[0])) .reduce((acc, entry): string => `${acc}; ${entry[0]}=${entry[1]}`, this.value); } } export interface LinkEntryParameters extends Record { /** Required rel properties of Link entry */ rel: string; } export interface LinkEntry { target: string; parameters: LinkEntryParameters; } // REUSED REGEXES const tchar = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u; const token = new RegExp(`^${tchar.source}+$`, 'u'); const mediaRange = new RegExp(`${tchar.source}+/${tchar.source}+`, 'u'); // HELPER FUNCTIONS /** * Replaces all double quoted strings in the input string with `"0"`, `"1"`, etc. * @param input - The Accept header string. * * @returns The transformed string and a map with keys `"0"`, etc. and values the original string that was there. */ export function transformQuotedStrings(input: string): { result: string; replacements: Record } { let idx = 0; const replacements: Record = {}; 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)) { logger.warn(`Invalid quoted string in header: ${match}`); throw new BadRequestHttpError(`Invalid quoted string in header: ${match}`); } const replacement = `"${idx}"`; replacements[replacement] = match.slice(1, -1); idx += 1; return replacement; }); return { result, replacements }; } /** * Splits the input string on commas, trims all parts and filters out empty ones. * * @param input - Input header string. */ export function splitAndClean(input: string): string[] { return 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 BadRequestHttpError} * Thrown on invalid syntax. */ function testQValue(qvalue: string): void { if (!/^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) { logger.warn(`Invalid q value: ${qvalue}`); throw new BadRequestHttpError( `Invalid q value: ${qvalue} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, ); } } /** * Parses a list of split parameters and checks their validity. * * @param parameters - A list of split parameters (token [ "=" ( token / quoted-string ) ]) * @param replacements - The double quoted strings that need to be replaced. * * * @throws {@link BadRequestHttpError} * Thrown on invalid parameter syntax. * * @returns An array of name/value objects corresponding to the parameters. */ export function parseParameters(parameters: string[], replacements: Record): { name: string; value: string }[] { return parameters.map((param): { name: string; value: string } => { const [ name, rawValue ] = param.split('=').map((str): string => str.trim()); // Test replaced string for easier check // parameter = token "=" ( token / quoted-string ) // second part is optional for certain parameters if (!(token.test(name) && (!rawValue || /^"\d+"$/u.test(rawValue) || token.test(rawValue)))) { logger.warn(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue}`); throw new BadRequestHttpError( `Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` + `does not match (token ( "=" ( token / quoted-string ))?). `, ); } let value = rawValue; if (value in replacements) { value = replacements[rawValue]; } return { name, value }; }); } /** * 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 BadRequestHttpError} * Thrown on invalid type, qvalue or parameter syntax. * * @returns {@link Accept} object corresponding to the header string. */ function parseAcceptPart(part: string, replacements: Record): 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 if (!mediaRange.test(range)) { logger.warn(`Invalid Accept range: ${range}`); throw new BadRequestHttpError( `Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`, ); } let weight = 1; const mediaTypeParams: Record = {}; const extensionParams: Record = {}; let map = mediaTypeParams; const parsedParams = parseParameters(parameters, replacements); parsedParams.forEach(({ name, value }): void => { if (name === 'q') { // Extension parameters appear after the q value map = extensionParams; testQValue(value); weight = Number.parseFloat(value); } else { if (!value && map !== extensionParams) { logger.warn(`Invalid Accept parameter ${name}`); throw new BadRequestHttpError(`Invalid Accept parameter ${name}: ` + `Accept parameter values are not optional when preceding the q value`); } map[name] = value || ''; } }); return { range, weight, parameters: { mediaType: mediaTypeParams, extension: extensionParams, }, }; } /** * 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 BadRequestHttpError} * Thrown on invalid qvalue syntax. * * @returns An array of ranges and weights. */ function parseNoParameters(input: string): AcceptHeader[] { const parts = splitAndClean(input); return parts.map((part): AcceptHeader => { const [ range, qvalue ] = part.split(';').map((param): string => param.trim()); const result = { range, weight: 1 }; if (qvalue) { if (!qvalue.startsWith('q=')) { logger.warn(`Only q parameters are allowed in ${input}`); throw new BadRequestHttpError(`Only q parameters are allowed in ${input}`); } const val = qvalue.slice(2); testQValue(val); result.weight = Number.parseFloat(val); } return result; }).sort((left, right): number => right.weight - left.weight); } // EXPORTED FUNCTIONS /** * Parses an Accept header string. * * @param input - The Accept header string. * * @throws {@link BadRequestHttpError} * Thrown on invalid header syntax. * * @returns An array of {@link Accept} objects, sorted by weight. */ export function parseAccept(input: string): Accept[] { // Quoted strings could prevent split from having correct results const { result, replacements } = transformQuotedStrings(input); return splitAndClean(result) .map((part): Accept => parseAcceptPart(part, replacements)) .sort((left, right): number => right.weight - left.weight); } /** * Parses an Accept-Charset header string. * * @param input - The Accept-Charset header string. * * @throws {@link BadRequestHttpError} * Thrown on invalid header syntax. * * @returns An array of {@link AcceptCharset} objects, sorted by weight. */ export function parseAcceptCharset(input: string): AcceptCharset[] { const results = parseNoParameters(input); results.forEach((result): void => { if (!token.test(result.range)) { logger.warn(`Invalid Accept-Charset range: ${result.range}`); throw new BadRequestHttpError( `Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`, ); } }); return results; } /** * Parses an Accept-Encoding header string. * * @param input - The Accept-Encoding header string. * * @throws {@link BadRequestHttpError} * Thrown on invalid header syntax. * * @returns An array of {@link AcceptEncoding} objects, sorted by weight. */ export function parseAcceptEncoding(input: string): AcceptEncoding[] { const results = parseNoParameters(input); results.forEach((result): void => { if (!token.test(result.range)) { logger.warn(`Invalid Accept-Encoding range: ${result.range}`); throw new BadRequestHttpError(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`); } }); return results; } /** * Parses an Accept-Language header string. * * @param input - The Accept-Language header string. * * @throws {@link BadRequestHttpError} * Thrown on invalid header syntax. * * @returns An array of {@link AcceptLanguage} objects, sorted by weight. */ export function 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)) { logger.warn( `Invalid Accept-Language range: ${result.range}`, ); throw new BadRequestHttpError( `Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`, ); } }); return results; } // eslint-disable-next-line max-len const rfc1123Date = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/u; /** * Parses an Accept-DateTime header string. * * @param input - The Accept-DateTime header string. * * @returns An array with a single {@link AcceptDatetime} object. */ export function parseAcceptDateTime(input: string): AcceptDatetime[] { const results: AcceptDatetime[] = []; const range = input.trim(); if (range) { if (!rfc1123Date.test(range)) { logger.warn( `Invalid Accept-DateTime range: ${range}`, ); throw new BadRequestHttpError( `Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`, ); } results.push({ range, weight: 1 }); } return results; } /** * Adds a header value without overriding previous values. */ export function addHeader(response: HttpResponse, name: string, value: string | string[]): void { let allValues: string[] = []; if (response.hasHeader(name)) { let oldValues = response.getHeader(name)!; if (typeof oldValues === 'string') { oldValues = [ oldValues ]; } else if (typeof oldValues === 'number') { oldValues = [ `${oldValues}` ]; } allValues = oldValues; } if (Array.isArray(value)) { allValues.push(...value); } else { allValues.push(value); } response.setHeader(name, allValues.length === 1 ? allValues[0] : allValues); } /** * Parses the Content-Type header and also parses any parameters in the header. * * @param input - The Content-Type header string. * * @throws {@link BadRequestHttpError} * Thrown on invalid header syntax. * * @returns A {@link ContentType} object containing the value and optional parameters. */ export function parseContentType(input: string): ContentType { // Quoted strings could prevent split from having correct results const { result, replacements } = transformQuotedStrings(input); const [ value, ...params ] = result.split(';').map((str): string => str.trim()); if (!mediaRange.test(value)) { logger.warn(`Invalid content-type: ${value}`); throw new BadRequestHttpError(`Invalid content-type: ${value} does not match ( token "/" token )`); } return parseParameters(params, replacements) .reduce( (prev, cur): ContentType => { prev.parameters[cur.name] = cur.value; return prev; }, new ContentType(value), ); } /** * The Forwarded header from RFC7239 */ export interface Forwarded { /** The user-agent facing interface of the proxy */ by?: string; /** The node making the request to the proxy */ for?: string; /** The host request header field as received by the proxy */ host?: string; /** The protocol used to make the request */ proto?: string; } /** * Parses a Forwarded header value and will fall back to X-Forwarded-* headers. * * @param headers - The incoming HTTP headers. * * @returns The parsed Forwarded header. */ export function parseForwarded(headers: IncomingHttpHeaders): Forwarded { const forwarded: Record = {}; if (headers.forwarded) { for (const pair of headers.forwarded.replace(/\s*,.*/u, '').split(';')) { const components = /^(by|for|host|proto)=(.+)$/u.exec(pair); if (components) { forwarded[components[1]] = components[2]; } } } else { const suffixes = [ 'host', 'proto' ]; for (const suffix of suffixes) { const value = headers[`x-forwarded-${suffix}`] as string; if (value) { forwarded[suffix] = value.trim().replace(/\s*,.*/u, ''); } } } return forwarded; } /** * Parses the link header(s) and returns an array of LinkEntry objects. * * @param link - A single link header or an array of link headers * @returns A LinkEntry array, LinkEntry contains a link and a params Record<string,string> */ export function parseLinkHeader(link: string | string[] = []): LinkEntry[] { const linkHeaders = Array.isArray(link) ? link : [ link ]; const links: LinkEntry[] = []; for (const entry of linkHeaders) { const { result, replacements } = transformQuotedStrings(entry); for (const part of splitAndClean(result)) { const [ target, ...parameters ] = part.split(/\s*;\s*/u); if (/^[^<]|[^>]$/u.test(target)) { logger.warn(`Invalid link header ${part}.`); continue; } // RFC 8288 - Web Linking (https://datatracker.ietf.org/doc/html/rfc8288) // // The rel parameter MUST be // present but MUST NOT appear more than once in a given link-value; // occurrences after the first MUST be ignored by parsers. // const params: any = {}; for (const { name, value } of parseParameters(parameters, replacements)) { if (name === 'rel' && 'rel' in params) { continue; } params[name] = value; } if (!('rel' in params)) { logger.warn(`Invalid link header ${part} contains no 'rel' parameter.`); continue; } links.push({ target: target.slice(1, -1), parameters: params }); } } return links; } /** * Checks if the value of an HTTP Authorization header matches a specific scheme (e.g. Basic, Bearer, etc). * * @param scheme - Name of the authorization scheme (case insensitive). * @param authorization - The value of the Authorization header (may be undefined). * @returns True if the Authorization header uses the specified scheme, false otherwise. */ export function matchesAuthorizationScheme(scheme: string, authorization?: string): boolean { const lowerCaseScheme = scheme.toLowerCase(); if (!authSchemeRegexCache.has(lowerCaseScheme)) { authSchemeRegexCache.set(lowerCaseScheme, new RegExp(`^${escapeStringRegexp(lowerCaseScheme)} `, 'ui')); } // Support authorization being undefined (for the sake of usability). return typeof authorization !== 'undefined' && authSchemeRegexCache.get(lowerCaseScheme)!.test(authorization); } /** * Checks if the scheme part of the specified url matches at least one of the provided options. * * @param url - A string representing the URL. * @param schemes - Scheme value options (the function will check if at least one matches the URL scheme). * @returns True if the URL scheme matches at least one of the provided options, false otherwise. */ export function hasScheme(url: string, ...schemes: string[]): boolean { const schemeOptions = new Set(schemes.map((item): string => item.toLowerCase())); const urlSchemeResult = /^(.+?):\/\//u.exec(url); return urlSchemeResult ? schemeOptions.has(urlSchemeResult[1].toLowerCase()) : false; }