// Copyright 2012 Joyent, Inc. All rights reserved. import { HEADER, HttpSignatureError, InvalidAlgorithmError, validateAlgorithm } from './utils' ///--- Globals const State = { New: 0, Params: 1, } const ParamsState = { Name: 0, Quote: 1, Value: 2, Comma: 3, Number: 4, } ///--- Specific Errors class ExpiredRequestError extends HttpSignatureError { constructor(message: string) { super(message, ExpiredRequestError) } } class InvalidHeaderError extends HttpSignatureError { constructor(message: string) { super(message, InvalidHeaderError) } } class InvalidParamsError extends HttpSignatureError { constructor(message: string) { super(message, InvalidParamsError) } } class MissingHeaderError extends HttpSignatureError { constructor(message: string) { super(message, MissingHeaderError) } } class StrictParsingError extends HttpSignatureError { constructor(message: string) { super(message, StrictParsingError) } } type Options = { clockSkew: number headers: string[] strict: boolean } export type ParsedSignature = { signature: string keyId: string signingString: string algorithm: string scheme: string params: Record opaque: string } ///--- Exported API /** * Parses the 'Authorization' header out of an http.ServerRequest object. * * Note that this API will fully validate the Authorization header, and throw * on any error. It will not however check the signature, or the keyId format * as those are specific to your environment. You can use the options object * to pass in extra constraints. * * As a response object you can expect this: * * { * "scheme": "Signature", * "params": { * "keyId": "foo", * "algorithm": "rsa-sha256", * "headers": [ * "date" or "x-date", * "digest" * ], * "signature": "base64" * }, * "signingString": "ready to be passed to crypto.verify()" * } * * @param {Object} request an http.ServerRequest. * @param {Object} options an optional options object with: * - clockSkew: allowed clock skew in seconds (default 300). * - headers: required header names (def: date or x-date) * - strict: should enforce latest spec parsing * (default: false). * @return {Object} parsed out object (see above). * @throws {TypeError} on invalid input. * @throws {InvalidHeaderError} on an invalid Authorization header error. * @throws {InvalidParamsError} if the params in the scheme are invalid. * @throws {MissingHeaderError} if the params indicate a header not present, * either in the request headers from the params, * or not in the params from a required header * in options. * @throws {StrictParsingError} if old attributes are used in strict parsing * mode. * @throws {ExpiredRequestError} if the value of date or x-date exceeds skew. */ export function parseRequest(request: Request, options?: Options): ParsedSignature { if (options === undefined) { options = { clockSkew: 300, headers: ['host', '(request-target)'], strict: false, } } if (request.method == 'POST') { options.headers.push('digest') } let headers = [request.headers.has('x-date') ? 'x-date' : 'date'] if (options.headers !== undefined) { headers = options.headers } const authz = request.headers.get(HEADER.AUTH) || request.headers.get(HEADER.SIG) if (!authz) { const errHeader = HEADER.AUTH + ' or ' + HEADER.SIG throw new MissingHeaderError('no ' + errHeader + ' header ' + 'present in the request') } options.clockSkew = options.clockSkew || 300 let i = 0 let state = authz === request.headers.get(HEADER.SIG) ? State.Params : State.New let substate = ParamsState.Name let tmpName = '' let tmpValue = '' const parsed: ParsedSignature = { scheme: authz === request.headers.get(HEADER.SIG) ? 'Signature' : '', params: {}, signingString: '', signature: '', keyId: '', algorithm: '', opaque: '', } for (i = 0; i < authz.length; i++) { const c = authz.charAt(i) let code = c.charCodeAt(0) switch (Number(state)) { case State.New: if (c !== ' ') parsed.scheme += c else state = State.Params break case State.Params: switch (Number(substate)) { case ParamsState.Name: // restricted name of A-Z / a-z if ( (code >= 0x41 && code <= 0x5a) || // A-Z (code >= 0x61 && code <= 0x7a) ) { // a-z tmpName += c } else if (c === '=') { if (tmpName.length === 0) throw new InvalidHeaderError('bad param format') substate = ParamsState.Quote } else { throw new InvalidHeaderError('bad param format') } break case ParamsState.Quote: if (c === '"') { tmpValue = '' substate = ParamsState.Value } else { //number substate = ParamsState.Number code = c.charCodeAt(0) if (code < 0x30 || code > 0x39) { //character not in 0-9 throw new InvalidHeaderError('bad param format') } tmpValue = c } break case ParamsState.Value: if (c === '"') { parsed.params[tmpName] = tmpValue substate = ParamsState.Comma } else { tmpValue += c } break case ParamsState.Number: if (c === ',') { parsed.params[tmpName] = parseInt(tmpValue, 10) tmpName = '' substate = ParamsState.Name } else { code = c.charCodeAt(0) if (code < 0x30 || code > 0x39) { //character not in 0-9 throw new InvalidHeaderError('bad param format') } tmpValue += c } break case ParamsState.Comma: if (c === ',') { tmpName = '' substate = ParamsState.Name } else { throw new InvalidHeaderError('bad param format') } break default: throw new Error('Invalid substate') } break default: throw new Error('Invalid substate') } } let parsedHeaders: string[] = [] if (!parsed.params.headers || parsed.params.headers === '') { if (request.headers.has('x-date')) { parsedHeaders = ['x-date'] } else { parsedHeaders = ['date'] } } else if (typeof parsed.params.headers === 'string') { parsedHeaders = parsed.params.headers.split(' ') } // Minimally validate the parsed object if (!parsed.scheme || parsed.scheme !== 'Signature') throw new InvalidHeaderError('scheme was not "Signature"') if (!parsed.params.keyId) throw new InvalidHeaderError('keyId was not specified') if (!parsed.params.algorithm) throw new InvalidHeaderError('algorithm was not specified') if (!parsed.params.signature) throw new InvalidHeaderError('signature was not specified') if (['date', 'x-date', '(created)'].every((hdr) => parsedHeaders.indexOf(hdr) < 0)) { throw new MissingHeaderError('no signed date header') } // Check the algorithm against the official list try { validateAlgorithm(parsed.params.algorithm as string, 'rsa') } catch (e) { if (e instanceof InvalidAlgorithmError) throw new InvalidParamsError(parsed.params.algorithm + ' is not ' + 'supported') else throw e } // Build the signingString for (i = 0; i < parsedHeaders.length; i++) { const h = parsedHeaders[i].toLowerCase() parsedHeaders[i] = h if (h === 'request-line') { if (!options.strict) { /* * We allow headers from the older spec drafts if strict parsing isn't * specified in options. */ parsed.signingString += request.method + ' ' + request.url + ' ' + request.cf?.httpProtocol } else { /* Strict parsing doesn't allow older draft headers. */ throw new StrictParsingError('request-line is not a valid header ' + 'with strict parsing enabled.') } } else if (h === '(request-target)') { const { pathname, search } = new URL(request.url) parsed.signingString += '(request-target): ' + `${request.method.toLowerCase()} ${pathname}${search}` } else if (h === '(keyid)') { parsed.signingString += '(keyid): ' + parsed.params.keyId } else if (h === '(algorithm)') { parsed.signingString += '(algorithm): ' + parsed.params.algorithm } else if (h === '(opaque)') { const opaque = parsed.params.opaque if (opaque === undefined) { //@ts-expect-error -- authzHeaderName doesn't exist TOFIX throw new MissingHeaderError('opaque param was not in the ' + authzHeaderName + ' header') } parsed.signingString += '(opaque): ' + opaque } else if (h === '(created)') { parsed.signingString += '(created): ' + parsed.params.created } else if (h === '(expires)') { parsed.signingString += '(expires): ' + parsed.params.expires } else { const value = request.headers.get(h) if (value === null) throw new MissingHeaderError(h + ' was not in the request') parsed.signingString += h + ': ' + value } if (i + 1 < parsedHeaders.length) parsed.signingString += '\n' } // Check against the constraints let date let skew if (request.headers.get('date') || request.headers.has('x-date')) { if (request.headers.has('x-date')) { date = new Date(request.headers.get('x-date') as string) } else { date = new Date(request.headers.get('date') as string) } const now = new Date() skew = Math.abs(now.getTime() - date.getTime()) if (skew > options.clockSkew * 1000) { throw new ExpiredRequestError('clock skew of ' + skew / 1000 + 's was greater than ' + options.clockSkew + 's') } } if (parsed.params.created && typeof parsed.params.created === 'number') { skew = parsed.params.created - Math.floor(Date.now() / 1000) if (skew > options.clockSkew) { throw new ExpiredRequestError( 'Created lies in the future (with ' + 'skew ' + skew + 's greater than allowed ' + options.clockSkew + 's' ) } if (Math.abs(skew) > options.clockSkew) { throw new ExpiredRequestError( 'clock skew of ' + Math.abs(skew) + 's greater than allowed ' + options.clockSkew + 's' ) } } if (parsed.params.expires && typeof parsed.params.expires === 'number') { const expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires if (expiredSince > options.clockSkew) { throw new ExpiredRequestError( 'Request expired with skew ' + expiredSince + 's greater than allowed ' + options.clockSkew + 's' ) } } headers.forEach(function (hdr) { // Remember that we already checked any headers in the params // were in the request, so if this passes we're good. if (parsedHeaders.indexOf(hdr.toLowerCase()) < 0) { throw new MissingHeaderError(hdr + ' was not a signed header') } }) const algorithm = parsed.params.algorithm as string parsed.params.algorithm = algorithm.toLowerCase() parsed.algorithm = algorithm.toUpperCase() parsed.keyId = parsed.params.keyId as string parsed.opaque = parsed.params.opaque as string parsed.signature = parsed.params.signature as string parsed.params.headers = parsedHeaders return parsed }