From: Justin Wind Date: Sun, 3 Aug 2025 22:07:15 +0000 (-0700) Subject: rework digest handling X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=e6db8ee3626e522f2968a8f539844544ce2f9550;p=squeep-api-dingus rework digest handling --- diff --git a/index.js b/index.js index dcf66e8..9ae0059 100644 --- a/index.js +++ b/index.js @@ -4,9 +4,11 @@ const Dingus = require('./lib/dingus'); const common = require('./lib/common'); const Enum = require('./lib/enum'); const Errors = require('./lib/errors'); +const Digest = require('./lib/digest'); module.exports = { common, + Digest, Dingus, Enum, Errors, diff --git a/lib/common.js b/lib/common.js index 4e4fd4e..109d4c3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -317,6 +317,24 @@ function addCookie(res, name, value, opt = {}) { res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; ')); } +/** + * Create a new object by applying mapFn to entries of another object. + * @param {object} obj object to remap + * @param {Function} mapFn map function ([k, v]) => ([k, v]) return undefined to omit + * @returns {object} remapped object + */ +function objectRemap(obj, mapFn) { + return Object.fromEntries( + Object.entries(obj) + .reduce((acc, entry) => { + const remappedEntry = mapFn(entry); + if (remappedEntry) { + acc.push(remappedEntry); + } + return acc; + }, [])); +} + module.exports = { addCookie, @@ -327,6 +345,7 @@ module.exports = { isClientCached, mergeDeep, mergeEnum, + objectRemap, pick, requestId, setOptions, diff --git a/lib/content-negotiation.js b/lib/content-negotiation.js index b941d89..7ea7e8a 100644 --- a/lib/content-negotiation.js +++ b/lib/content-negotiation.js @@ -170,27 +170,7 @@ class ContentNegotiation { } - // inverted lowercased index for matching and response - static digestAlgorithmMap = Object.fromEntries( - Object.entries(Enum.DigestAlgorithm) - .map(([k, v]) => ([v.toLowerCase(), k]))); - /** - * Case-insensitive best match returns hash algorithm, e.g. sha512 - * @param {string} digestHeader Want-Digest header value - * @param {string[]=} availableDigests supported digests from RFC3230 - * @returns {string|undefined} best-match algorithm from requested digests - */ - static digestWanted(digestHeader, availableDigests) { - if (!availableDigests) { - availableDigests = Object.keys(this.digestAlgorithmMap); - } - const digestClauses = this._acceptClauses(digestHeader) - .map((clause) => ({ ...clause, type: clause.type.toLowerCase() })) - .filter((clause) => clause.weight && availableDigests.includes(clause.type)) - ; - return (digestClauses.length) ? this.digestAlgorithmMap[digestClauses[0].type] : undefined; - } } module.exports = ContentNegotiation; diff --git a/lib/digest.js b/lib/digest.js new file mode 100644 index 0000000..dcae27c --- /dev/null +++ b/lib/digest.js @@ -0,0 +1,431 @@ +'use strict'; + +const crypto = require('node:crypto'); +const common = require('./common'); +const Enum = require('./enum'); + +/** + * @typedef {import('node:http')} http + */ + +/** + * Share a digest generator between multiple headers, if needed. + * @typedef {object} Digester + * @property {Function} hash digester function + * @property {string} base64 completed digest + * @property {string[]} targetHeaders headers wanting to include this digest + */ +/** + * We track a mapping of internal algoritm name to digester object. + * @typedef {Record} Digesters + */ + +/** + * Helpers for dealing with limited scopes of various digest-header specifications. + */ +class Digest { + // Private property for computing digests. + static kDigesters = Symbol.for('squeep-dingus-digesters'); + + // Private property for proffered digests. + static kDigests = Symbol.for('squeep-dingus-digests'); + + // Length of digest buffer + static algorithmBytes = { + 'sha256': 32, + 'sha512': 64, + 'sha1': 20, + 'md5': 16, + }; + + static algorithmHashToRFC3230 = { + 'sha256': 'SHA-256', + 'sha512': 'SHA-512', + 'sha1': 'SHA', + 'md5': 'MD5', + }; + static algorithmRFC3230ToHash = common.objectRemap(this.algorithmHashToRFC3230, this._entryInvertLowercaseMapper); + + static algorithmHashToRFC9530 = { + 'sha256': 'sha-256', + 'sha512': 'sha-512', + }; + static algorithmRFC9530ToHash = common.objectRemap(this.algorithmHashToRFC9530, this._entryInvertLowercaseMapper); + + static algorithmsSupported = new Set(Object.keys(this.algorithmBytes)); + + /** + * @param {any[]} objectEntry entry + * @returns {any[]} remapped entry + */ + static _entryInvertLowercaseMapper([k, v]) { + return [v.toLowerCase(), k]; + } + + + /** + * @typedef {Record} ParsedDigests + */ + /** + * Parse RFC3230 Digest header value into proffered digests. + * @param {string} headerValue Digest header value + * @returns {ParsedDigests} proffered digests + */ + static parseDigestRFC3230(headerValue) { + const digests = {}; + for (const digestClause of headerValue.split(',')) { + const [hash, value] = common.splitFirst(digestClause, '=', '').map((v) => v.trim()); + const normalizedHash = hash.toLowerCase(); + /* eslint-disable security/detect-object-injection */ + if ((normalizedHash in this.algorithmRFC3230ToHash) && value) { + const alg = this.algorithmRFC3230ToHash[normalizedHash]; + const decoded = Buffer.from(value, 'base64'); + if (decoded.length !== this.algorithmBytes[alg]) { + continue; + } + digests[alg] = decoded; + } + /* eslint-ensable security/detect-object-injection */ + } + return digests; + } + + + /** + * Parse RFC9530 Content-Digest or Repr-Digest header value into proffered digests. + * @param {string} headerValue Content-Digest or Repr-Digest header value + * @returns {ParsedDigests} proffered digests + */ + static parseDigestRFC9530(headerValue) { + const digests = {}; + for (const digestClause of headerValue.split(',')) { + const [hash, value] = common.splitFirst(digestClause, '=', '').map((v) => v.trim()); + const normalizedHash = hash.toLowerCase(); + if (!(value.startsWith(':') && value.endsWith(':'))) { + continue; + } + const normalizedValue = value.slice(1, value.length - 1); + /* eslint-disable security/detect-object-injection */ + if ((normalizedHash in this.algorithmRFC9530ToHash) && normalizedValue) { + const alg = this.algorithmRFC9530ToHash[normalizedHash]; + const decoded = Buffer.from(normalizedValue, 'base64'); + if (decoded.length !== this.algorithmBytes[alg]) { + continue; + } + digests[alg] = decoded; + } + /* eslint-ensable security/detect-object-injection */ + } + return digests; + } + + + /** + * Update all hashes on an object. + * @param {object} obj has digester property + * @param {string|Buffer} chunk data + * @param {string} encoding encoding + */ + static feedDigesters(obj, chunk, encoding) { + const digesters = Object.values(obj[this.kDigesters] ?? {}); + for (const digester of digesters) { + digester.hash.update(chunk, encoding); + } + } + + + /** + * Finalize all hashes on an object. + * @param {object} obj has digester property + */ + static finishDigesters(obj) { + const digesters = Object.values(obj[this.kDigesters] ?? {}); + for (const digester of digesters) { + digester.base64 = digester.hash.digest('base64'); + } + } + + + /** + * Ensure only one layer of digest wrappers are applied to a response. + * Wraps write method to update the requested digests. + * Wraps end method to set the requested digest headers before calling end. + * Currently only supports setting Content-Digest and Digest headers. + * @param {http.ServerResponse} res response + * @returns {Digesters} response digesters + */ + static hookResponseDigesters(res) { + if ((this.kDigesters in res)) { + return res[this.kDigesters]; + } + + const digesters = (res[this.kDigesters] = {}); + + const origWrite = res.write.bind(res); + res.write = function digestWrite(chunk, encoding, cb) { + for (const digester of Object.values(digesters)) { + digester.hash.update(chunk, encoding); + } + return origWrite.call(chunk, encoding, cb); + }; + + const origEnd = res.end.bind(res); + const Digest = this; + res.end = function digestEnd(data, encoding, cb) { + const headerClauses = {}; + for (const [algorithm, digester] of Object.entries(digesters)) { + if (data) { + digester.hash.update(data, encoding); + } + digester.base64 = digester.hash.digest('base64'); + for (const targetHeader of digester.targetHeaders) { + let lut; + let digest; + switch (targetHeader) { + case Enum.Header.Digest: + lut = Digest.algorithmHashToRFC3230; + digest = digester.base64; + break; + + case Enum.Header.ContentDigest: + case Enum.Header.ReprDigest: + lut = Digest.algorithmHashToRFC9530; + digest = `:${digester.base64}:`; + break; + + default: + continue; + } + if (!(targetHeader in headerClauses)) { + headerClauses[targetHeader] = []; + } + headerClauses[targetHeader].push(`${lut[algorithm]}=${digest}`); + } + for (const [header, clauses] of Object.entries(headerClauses)) { + res.setHeader(header, clauses.join(',')); + } + } + return origEnd(data, encoding, cb); + }; + + return digesters; + } + + + /** + * Add RFC9530 Content-Digest header to response which includes the specified algorithms. + * May be called multiple times to add additional algorithms. + * @param {http.ServerResponse} res response + * @param {string[]=} algorithms digest algorithms to include, name of crypto hash + */ + static addContentDigestRFC9530(res, algorithms = ['sha256']) { + const digesters = this.hookResponseDigesters(res); + + for (const algorithm of algorithms) { + if (algorithm in this.algorithmHashToRFC9530) { + if (!(algorithm in digesters)) { + digesters[algorithm] = { + hash: crypto.createHash(algorithm), + base64: undefined, + targetHeaders: new Set(), + }; + } + const digester = digesters[algorithm]; + digester.targetHeaders.add(Enum.Header.ContentDigest); + } + } + } + + + /** + * Best match of requested Content Digests, returns hash algorithm, e.g. sha512 + * @param {string} digestHeader Want-Content-Digest header value + * @returns {string|undefined} best-match algorithm from requested digests + */ + static contentDigestWanted(digestHeader) { + const digestClauses = this._digestClausesRFC9530(digestHeader) + .filter((clause) => clause.weight); + return (digestClauses.length) ? digestClauses[0].algorithm : undefined; + } + + + /** + * Parse RFC9530 header into sorted list of valid desired algorithms. + * In this spec, weights are integers [0,10] + * @param {string} header want-content-digest header + * @returns {object[]} sorted list of requested digest hash algorithms + */ + static _digestClausesRFC9530(header) { + const clauses = (header||'').split(',') + .map((clause) => { + const params = clause.split(';'); + const algorithm = params.shift().trim().toLowerCase(); + if (algorithm && (algorithm in this.algorithmRFC9530ToHash)) { + let weight = 10; + for (const param of params) { + const [p, v] = common.splitFirst(param, '=').map((x) => x?.trim()); + if (p === 'q') { + weight = parseInt(v, 10); + if (weight === 0 || !Number.isFinite(weight)) { + return undefined; + } + weight = Math.min(Math.max(weight, 0), 10); + } + } + return { + algorithm: this.algorithmRFC3230ToHash[algorithm], + weight, + }; + } + }) + .filter((clause) => clause); + return clauses.sort((a, b) => b.weight - a.weight); + } + + + /** + * Add RFC3230 Digest header to response which includes the specified algorithms. + * May be called multiple times to add additional algorithms. + * @param {http.ServerResponse} res response + * @param {string[]=} algorithms digest algorithms to include, name of crypto hash + */ + static addDigestRFC3230(res, algorithms = ['sha256']) { + const digesters = this.hookResponseDigesters(res); + + for (const algorithm of algorithms) { + if (algorithm in this.algorithmHashToRFC3230) { + if (!(algorithm in digesters)) { + digesters[algorithm] = { + hash: crypto.createHash(algorithm), + digest: undefined, + targetHeaders: new Set(), + }; + } + const digester = digesters[algorithm]; + digester.targetHeaders.add(Enum.Header.Digest); + } + } + } + + + /** + * Best match of requested Digests, returns hash algorithm, e.g. sha512 + * @param {string} digestHeader Want-Digest header value + * @returns {string|undefined} best-match algorithm from requested digests + */ + static digestWanted(digestHeader) { + const digestClauses = this._digestClausesRFC3230(digestHeader); + return (digestClauses.length) ? digestClauses[0].algorithm : undefined; + } + + + /** + * Parse RFC3230 header into sorted list of valid desired algorithms. + * In this spec, weights are fractional qvalues. + * @param {string} header want-digest header + * @returns {object[]} sorted list of requested digest hash algorithms + */ + static _digestClausesRFC3230(header) { + const clauses = (header||'').split(',') + .map((clause) => { + const params = clause.split(';'); + const algorithm = params.shift().trim().toLowerCase(); + if (algorithm && (algorithm in this.algorithmRFC3230ToHash)) { + let weight = 1.0; + for (const param of params) { + const [p, v] = common.splitFirst(param, '=').map((x) => x?.trim()); + if (p === 'q') { + weight = Number(v); + if ((weight === 0) || (!Number.isFinite(weight))) { + return undefined; + } + } + } + return { + algorithm: this.algorithmRFC3230ToHash[algorithm], + weight, + }; + } + }) + .filter((clause) => clause); + return clauses.sort((a, b) => b.weight - a.weight); + } + + + /** + * Register a digester. + * @param {object} obj request or response + * @param {string} algorithm crypto hash algorithm + * @param {string} header header of interest for this algorithm + * @returns {Digesters} digesters + */ + static _addDigester(obj, algorithm, header) { + if (!obj[this.kDigesters]) { + obj[this.kDigesters] = {}; + } + const digesters = obj[this.kDigesters]; + if (this.algorithmsSupported.has(algorithm)) { + if (!(algorithm in digesters)) { + digesters[algorithm] = { + hash: crypto.createHash(algorithm), + base64: undefined, + targetHeaders: new Set(), + }; + } + digesters[algorithm].targetHeaders.add(header); + } + return digesters; + } + + + /** + * If digest headers are present, parse and configure Digest processing. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + */ + static establishRequestedDigesters(req, res) { + // Outgoing digests requested, prepare to compute. + const wantDigestHeader = req.getHeader(Enum.Header.WantDigest); + if (wantDigestHeader) { + const wantedDigest = this.digestWanted(wantDigestHeader); + if (wantedDigest) { + this.addDigestRFC3230(res, [wantedDigest]); + } + } + + const wantContentDigestHeader = req.getHeader(Enum.Header.WantContentDigest); + if (wantContentDigestHeader) { + const wantedContentDigest = this.contentDigestWanted(wantContentDigestHeader); + if (wantedContentDigest) { + this.addContentDigestRFC9530(res, [wantedContentDigest]); + } + } + + // Incoming digests, prepare to validate. + const digestHeader = req.getHeader(Enum.Header.Digest); + if (digestHeader) { + req[this.kDigests] = { + ...req[this.kDigests], + ...this.parseDigestRFC3230(digestHeader), + }; + for (const algorithm of Object.keys(req[this.kDigests])) { + this._addDigester(req, algorithm, Enum.Header.Digest); + } + } + + const contentDigestHeader = req.getHeader(Enum.Header.ContentDigest); + if (contentDigestHeader) { + req[this.kDigests] = { + ...req[this.kDigests], + ...this.parseDigestRFC9530(contentDigestHeader), + }; + for (const algorithm of Object.keys(req[this.kDigests])) { + this._addDigester(req, algorithm, Enum.Header.ContentDigest); + } + } + } + +} + + +module.exports = Digest; \ No newline at end of file diff --git a/lib/dingus.js b/lib/dingus.js index ac91038..adbcf01 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -10,12 +10,12 @@ */ require('./patches'); -const crypto = require('node:crypto'); const { promises: fsPromises } = require('node:fs'); const path = require('node:path'); const querystring = require('node:querystring'); const common = require('./common'); const ContentNegotiation = require('./content-negotiation'); +const Digest = require('./digest'); const Enum = require('./enum'); const { DingusError, ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors'); const { extensionToMime } = require('./mime-helper'); @@ -63,7 +63,6 @@ const proxySplitRE = /\s*,\s*/u; */ class Dingus { - static kResponseDigesters = Symbol.for('squeep-dingus-response-digesters'); /** * @param {object} logger console-like object which implements logging methods @@ -392,65 +391,6 @@ class Dingus { } - /** - * If request included Want-Digest header, parse and configure Digest processing. - * RFC3230 Digest - * Additional digests can be added explicity with addDigest. - * @param {http.ClientRequest} req request - * @param {http.ServerResponse} res response - */ - static establishRequestedDigesters(req, res) { - const wantDigestHeader = req.getHeader(Enum.Header.WantDigest); - if (wantDigestHeader) { - const wantedDigest = ContentNegotiation.digestWanted(wantDigestHeader); - if (wantedDigest) { - this.addDigest(res, [wantedDigest]); - } - } - } - - - /** - * Add RFC3230 Digest header to response. - * @param {http.ServerResponse} res response - * @param {string[]=} algorithms digest algorithms to include - */ - static addDigest(res, algorithms = ['sha256']) { - if (!(this.kResponseDigesters in res)) { - res[this.kResponseDigesters] = {}; - - const origWrite = res.write.bind(res); - res.write = function digestWrite(chunk, ...rest) { - for (const digester of Object.values(digesters)) { - digester.update(chunk); - } - return origWrite.call(chunk, ...rest); - }; - - const origEnd = res.end.bind(res); - res.end = function digestEnd(data, ...rest) { - for (const digester of Object.values(digesters)) { - digester.update(data); - } - const digestValues = Object.entries(digesters).map(([algorithm, digester]) => { - // eslint-disable-next-line security/detect-object-injection - return `${Enum.DigestAlgorithm[algorithm]}=${digester.digest('base64')}`; - }); - res.setHeader(Enum.Header.Digest, digestValues.join(',')); - return origEnd(data, ...rest); - }; - } - const digesters = res[this.kResponseDigesters]; - - for (const algorithm of algorithms) { - if (!(algorithm in digesters) && algorithm in Enum.DigestAlgorithm) { - // eslint-disable-next-line security/detect-object-injection - digesters[algorithm] = crypto.createHash(algorithm); - } - } - } - - /** * Called before every request handler. * Sets tracking identifiers and client information on ctx. @@ -462,7 +402,7 @@ class Dingus { this.constructor.tagContext(req, res, ctx); this.clientAddressContext(req, res, ctx); this.constructor.ingestCookie(req, res, ctx); - this.constructor.establishRequestedDigesters(req, res); + Digest.establishRequestedDigesters(req, res); } @@ -675,6 +615,7 @@ class Dingus { let length = 0; req.on('data', (chunk) => { body.push(chunk); + Digest.feedDigesters(req, chunk); length += Buffer.byteLength(chunk); if (maximumBodySize && length > maximumBodySize) { this.logger.debug(_scope, 'body data exceeded limit', { length, maximumBodySize }); @@ -683,6 +624,7 @@ class Dingus { }); req.on('end', () => { const bodyBuffer = Buffer.concat(body); + Digest.finishDigesters(req); resolve(toString ? bodyBuffer.toString() : bodyBuffer); }); req.on('error', (e) => { @@ -695,7 +637,8 @@ class Dingus { /** * Read and parse request body data. - * Sets ctx.parsedBody, and optionally ctx.rawBody. + * Sets ctx.parsedBody, and optionally ctx.rawBody and ctx.integrity. + * If Digest or Content-Digest headers supplied in request, tally any maybe enforce those hashes. * @param {http.ClientRequest} req request * @param {http.ServerResponse} res response * @param {object} ctx context @@ -703,13 +646,40 @@ class Dingus { * @param {boolean} options.parseEmptyBody attempt to parse an empty body, results may vary by encoding * @param {boolean} options.persistRawBody store raw body buffer on context * @param {number=} options.maximumBodySize bytes + * @param {boolean} options.validateDigests if digests present, reject any mismatch + * @param {object} options.digestFailureErrorResponse errorResponse object to throw on digest mismatch + * @param {boolean} options.persistDigests store raw body buffer on context */ - async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) { - const rawBody = await this.bodyData(req, maximumBodySize); - if (persistRawBody) { + async ingestBody(req, res, ctx, options) { + options = { + parseEmptyBody: true, + persistRawBody: false, + maximumBodySize: undefined, + validateDigests: true, + digestFailureErrorResponse: Enum.ErrorResponse.BadRequest, + persistDigests: true, + ...options, + }; + const rawBody = await this.bodyData(req, options.maximumBodySize); + if (options.persistRawBody) { ctx.rawBody = rawBody; } - if (rawBody || parseEmptyBody) { + const invalidDigests = []; + const integrity = common.objectRemap(req[Digest.kDigests]??{}, ([alg, buf]) => { + const computed = req[Digest.kDigesters]?.[alg]?.base64; // eslint-disable-line security/detect-object-injection + const valid = computed === buf.toString('base64'); + if (!valid) { + invalidDigests.push(alg); + } + return [alg, valid]; + }); + if (options.persistDigests) { + ctx.integrity = integrity; + } + if (options.validateDigests && invalidDigests.length) { + throw new ResponseError(options.digestFailureErrorResponse, invalidDigests); + } + if (rawBody || options.parseEmptyBody) { const contentType = Dingus.getRequestContentType(req); this.parseBody(contentType, ctx, rawBody); } diff --git a/lib/enum.js b/lib/enum.js index 4479826..02337f7 100644 --- a/lib/enum.js +++ b/lib/enum.js @@ -212,6 +212,7 @@ const Header = { AcceptEncoding: 'Accept-Encoding', Allow: 'Allow', CacheControl: 'Cache-Control', + ContentDigest: 'Content-Digest', ContentEncoding: 'Content-Encoding', ContentLength: 'Content-Length', ContentType: 'Content-Type', @@ -222,10 +223,13 @@ const Header = { IfNoneMatch: 'If-None-Match', LastModified: 'Last-Modified', Location: 'Location', + ReprDigest: 'Repr-Digest', RequestId: 'Request-ID', SetCookie: 'Set-Cookie', Vary: 'Vary', + WantContentDigest: 'Want-Content-Digest', WantDigest: 'Want-Digest', + WantReprDigest: 'Want-Repr-Digest', XCorrelationId: 'X-Correlation-ID', XForwardedFor: 'X-Forwarded-For', XForwardedProto: 'X-Forwarded-Proto', diff --git a/test/lib/common.js b/test/lib/common.js index e1e47c4..57ab8ea 100644 --- a/test/lib/common.js +++ b/test/lib/common.js @@ -419,4 +419,16 @@ describe('Common', function () { }); }); // addCookie + describe('objectRemap', function () { + it('remaps an object', function () { + const obj = { a: 1, b: 2, c: 3 }; + const mapFn = ([k, v]) => { + return v !== 2 ? [k, v + 1] : undefined; + }; + const expected = { a: 2, c: 4 }; + const result = common.objectRemap(obj, mapFn); + assert.deepStrictEqual(result, expected); + }); + }); // objectRemap + }); // Common diff --git a/test/lib/content-negotiation.js b/test/lib/content-negotiation.js index 1185efd..238f457 100644 --- a/test/lib/content-negotiation.js +++ b/test/lib/content-negotiation.js @@ -201,20 +201,5 @@ describe('ContentNegotiation', function () { }); }); // preferred - describe('digestWanted', function () { - it('chooses most-preferred', function () { - const result = ContentNegotiation.digestWanted('SHA;q=.5,blah, md5;q=.2, sha-256'); - assert.strictEqual(result, 'sha256'); - }); - it('chooses from a subset', function () { - const result = ContentNegotiation.digestWanted('SHA;q=.5,blah, md5;q=.2, sha-256', ['md5', 'sha-512']); - assert.strictEqual(result, 'md5'); - }); - it('finds nothing', function () { - const result = ContentNegotiation.digestWanted('blah, pqc'); - assert.strictEqual(result, undefined); - }); - }); // digestWanted - }); // ContentNegotiation diff --git a/test/lib/digest.js b/test/lib/digest.js new file mode 100644 index 0000000..400def5 --- /dev/null +++ b/test/lib/digest.js @@ -0,0 +1,345 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); +const Digest = require('../../lib/digest'); +const { objectRemap } = require('../../lib/common'); + +describe('Digest', function () { + // 'Jackdaws love my big sphinx of quartz.' + const digestBase64 = { + md5: 'kVJUbr1MOoyKQ5GMBGNW7Q==', + sha1: '8TZQyT5s7MdZJThL9iNRxnceqs4=', + sha256: 'HzAySnGey8YnCO8Kt86/CuaFZQa5O9KdbY/7Nw5u9tY=', + sha512: 'pjCZnGFzjz4GbXmhspmilcXQWYwXPgkE0EpwfUOYjj6BZgv8Gxd5N39Owm+DfRuzH6K4YMmtLTdJXYPeMmR/6g==', + }; + const digestBuffer = objectRemap(digestBase64, ([k, v]) => ([k, Buffer.from(v, 'base64')])); + + afterEach(function () { + sinon.restore(); + }); + + describe('parseDigestRFC3230', function () { + it('parses known digests, ignores unknowns', function () { + const header = 'md5=kVJUbr1MOoyKQ5GMBGNW7Q==,SHA=8TZQyT5s7MdZJThL9iNRxnceqs4=, SHA-256=HzAySnGey8YnCO8Kt86/CuaFZQa5O9KdbY/7Nw5u9tY=,sha-512=pjCZnGFzjz4GbXmhspmilcXQWYwXPgkE0EpwfUOYjj6BZgv8Gxd5N39Owm+DfRuzH6K4YMmtLTdJXYPeMmR/6g==, SHAKE-128=2JFUcuFLWKSzJ9h4KMJioQ=='; + const expected = digestBuffer; + const result = Digest.parseDigestRFC3230(header); + assert.deepStrictEqual(result, expected); + }); + it('ignores invalid', function () { + const header = 'sha-256=8TZQyT5s7MdZJThL9iNRxnceqs4='; + const expected = {}; + const result = Digest.parseDigestRFC3230(header); + assert.deepStrictEqual(result, expected); + }); + }); // parseDigestRFC3230 + + describe('parseDigestRFC9530', function () { + it('parses known digests, ignores unknowns', function () { + const header = 'SHA=:8TZQyT5s7MdZJThL9iNRxnceqs4=:, SHA-256=:HzAySnGey8YnCO8Kt86/CuaFZQa5O9KdbY/7Nw5u9tY=:,sha-512=:pjCZnGFzjz4GbXmhspmilcXQWYwXPgkE0EpwfUOYjj6BZgv8Gxd5N39Owm+DfRuzH6K4YMmtLTdJXYPeMmR/6g==:'; + const expected = { + sha256: digestBuffer.sha256, + sha512: digestBuffer.sha512, + }; + const result = Digest.parseDigestRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + it('ignores invalid', function () { + const header = 'sha-256=HzAySnGey8YnCO8Kt86/CuaFZQa5O9KdbY/7Nw5u9tY=,sha-512=:HzAySnGey8YnCO8Kt86/CuaFZQa5O9KdbY/7Nw5u9tY=:'; + const expected = {}; + const result = Digest.parseDigestRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + }); // parseDigestRFC9530 + + describe('feedDigesters', function () { + let obj, update; + beforeEach(function () { + update = sinon.stub(); + obj = { + [Digest.kDigesters]: { + alg: { + hash: { + update, + }, + }, + }, + }; + }); + it('updates', function () { + Digest.feedDigesters(obj, 'data'); + assert(update.called); + }); + }); // feedDigesters + + describe('finishDigesters', function () { + let obj, digest; + beforeEach(function () { + digest = sinon.stub(); + obj = { + [Digest.kDigesters]: { + alg: { + hash: { + digest, + }, + }, + }, + }; + }); + it('digests', function () { + Digest.finishDigesters(obj, 'data'); + assert(digest.called); + }); + }); // finishDigesters + + describe('hookResponseDigesters', function () { + let res, end, write; + beforeEach(function () { + end = sinon.stub(); + write = sinon.stub(); + res = { + write, + end, + setHeader: sinon.stub(), + }; + }); + it('sets up hooks', function () { + Digest.hookResponseDigesters(res); + Digest._addDigester(res, 'sha256', 'Content-Digest'); + Digest._addDigester(res, 'sha256', 'Digest'); + Digest._addDigester(res, 'sha1', 'Digest'); + Digest._addDigester(res, 'md5', 'Deprecated-Digest'); + res.write('data'); + res.end('more data'); + assert.strictEqual(write.callCount, 1); + assert(end.callCount, 1); + }); + it('only sets up hooks once', function () { + Digest.hookResponseDigesters(res); + Digest.hookResponseDigesters(res); + Digest._addDigester(res, 'sha256', 'Content-Digest'); + Digest._addDigester(res, 'md5', 'Deprecated-Digest'); + res.write('data'); + res.end(); + assert.strictEqual(write.callCount, 1); + assert(end.callCount, 1); + }); + }); // hookResponseDigesters + + describe('addContentDigestRFC9530', function () { + let res, end, write; + beforeEach(function () { + end = sinon.stub(); + write = sinon.stub(); + res = { + end, + write, + setHeader: sinon.stub(), + }; + }); + it('adds default digest', function () { + Digest.addContentDigestRFC9530(res); + res.write('data'); + assert(write.called); + res.end('more data'); + assert(end.called); + assert(res.setHeader.called); + }); + it('adds valid digests', function () { + Digest.addContentDigestRFC9530(res, ['sha512', 'flarp128']); + Digest.addContentDigestRFC9530(res, ['sha512']); + res.write('data'); + assert(write.called); + res.end('more data'); + assert(end.called); + assert(res.setHeader.called); + }); + }); // addContentDigestRFC9530 + + describe('contentDigestWanted', function () { + it('chooses most-preferred', function () { + const result = Digest.contentDigestWanted('sha-256;q=2,sha-512'); + assert.strictEqual(result, 'sha512'); + }); + it('finds nothing', function () { + const result = Digest.contentDigestWanted('blah, pqc'); + assert.strictEqual(result, undefined); + }); + + }); // contentDigestWanted + + describe('_digestClausesRFC9530', function () { + it('parses expected', function () { + const header = 'sha-256, sha-512; q=1, blat; q=1'; + const expected = [ + { algorithm: 'sha256', weight: 10 }, + { algorithm: 'sha512', weight: 1 }, + ]; + const result = Digest._digestClausesRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + it('honors zeros', function () { + const header = 'sha-256; q=Infinity, sha-512; q=0'; + const expected = []; + const result = Digest._digestClausesRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + }); // _digestClausesRFC9530 + + describe('addDigestRFC3230', function () { + let res, end, write; + beforeEach(function () { + end = sinon.stub(); + write = sinon.stub(); + res = { + end, + write, + setHeader: sinon.stub(), + }; + }); + it('adds default digest', function () { + Digest.addDigestRFC3230(res); + res.write('data'); + assert(write.called); + res.end('more data'); + assert(end.called); + assert(res.setHeader.called); + }); + it('adds valid digests', function () { + Digest.addDigestRFC3230(res, ['md5', 'flarp128']); + Digest.addDigestRFC3230(res, ['md5']); + res.write('data'); + assert(write.called); + res.end('more data'); + assert(end.called); + assert(res.setHeader.called); + }); + }); // addDigestRFC3230 + + describe('digestWanted', function () { + it('chooses most-preferred', function () { + const result = Digest.digestWanted('SHA;q=.5,blah, md5;q=.2, sha-256'); + assert.strictEqual(result, 'sha256'); + }); + it('finds nothing', function () { + const result = Digest.digestWanted('blah, pqc'); + assert.strictEqual(result, undefined); + }); + }); // digestWanted + + describe('_digestClausesRFC9530', function () { + it('parses expected', function () { + const header = 'md5; q=.3, sha; q=Infinity, sha-256, sha-512; q=1, blat; q=1'; + const expected = [ + { algorithm: 'sha256', weight: 10 }, + { algorithm: 'sha512', weight: 1 }, + ]; + const result = Digest._digestClausesRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + it('honors zeros', function () { + const header = 'sha-256; arg=barg; q=Infinity, sha-512; q=0'; + const expected = []; + const result = Digest._digestClausesRFC9530(header); + assert.deepStrictEqual(result, expected); + }); + it('covers empty header', function () { + const expected = []; + const result = Digest._digestClausesRFC9530(); + assert.deepStrictEqual(result, expected); + }); + }); // _digestClausesRFC9530 + + describe('_digestClausesRFC3230', function () { + it('parses expected', function () { + const header = 'md5; q=.3, sha; q=Infinity, sha-256, sha-512; q=1, blat; q=1'; + const expected = [ + { algorithm: 'sha256', weight: 1 }, + { algorithm: 'sha512', weight: 1 }, + { algorithm: 'md5', weight: 0.3 }, + ]; + const result = Digest._digestClausesRFC3230(header); + assert.deepStrictEqual(result, expected); + }); + it('honors zeros, ignores weird', function () { + const header = 'sha-256; q=Infinity;x=yar, sha-512; foo=baz; q=0, md5;q=;='; + const expected = []; + const result = Digest._digestClausesRFC3230(header); + assert.deepStrictEqual(result, expected); + }); + it('covers empty header', function () { + const expected = []; + const result = Digest._digestClausesRFC3230(); + assert.deepStrictEqual(result, expected); + }); + }); // _digestClausesRFC3230 + + describe('_addDigester', function () { + it('covers', function () { + const obj = {}; + Digest._addDigester(obj, 'sha256', 'Digest'); + Digest._addDigester(obj, 'sha512', 'Content-Digest'); + Digest._addDigester(obj, 'flarp128', 'Invalid-Digest'); + assert('sha256' in obj[Digest.kDigesters]); + assert('sha512' in obj[Digest.kDigesters]); + }); + }); // _addDigester + + describe('establishRequestedDigesters', function () { + let req, res; + beforeEach(function () { + sinon.stub(Digest, 'addDigestRFC3230'); + sinon.stub(Digest, 'addContentDigestRFC9530'); + sinon.stub(Digest, '_addDigester'); + req = { + getHeader: sinon.stub() + .onCall(0).returns() // Want-Digest + .onCall(1).returns() // Want-Content-Digest + .onCall(2).returns() // Digest + .onCall(3).returns(), // Content-Digest + }; + res = { + write: sinon.stub(), + end: sinon.stub(), + }; + }); + it('does nothing if no headers', function () { + Digest.establishRequestedDigesters(req, res); + assert(Digest.addDigestRFC3230.notCalled); + assert(Digest.addContentDigestRFC9530.notCalled); + assert(Digest._addDigester.notCalled); + + }); + it('adds wanted response digests', function () { + req.getHeader.onCall(0).returns('SHA-512, sha-256, sha, md5'); + Digest.establishRequestedDigesters(req, res); + assert(Digest.addDigestRFC3230.called); + }); + it('does not add unknown wanted response digests', function () { + req.getHeader.onCall(0).returns('FLARP-512, baz'); + Digest.establishRequestedDigesters(req, res); + assert(Digest.addDigestRFC3230.notCalled); + }); + it('adds wanted response content digests', function () { + req.getHeader.onCall(1).returns('SHA-512, sha-256'); + Digest.establishRequestedDigesters(req, res); + assert(Digest.addContentDigestRFC9530.called); + }); + it('does not add unknown wanted response content digests', function () { + req.getHeader.onCall(1).returns('FLARP-512, baz'); + Digest.establishRequestedDigesters(req, res); + assert(Digest.addContentDigestRFC9530.notCalled); + }); + it('prepares Digest', function () { + req.getHeader.onCall(2).returns(`sha-256=${digestBase64.sha256}, sha-512=${digestBase64.sha512}`); + Digest.establishRequestedDigesters(req, res); + assert(Digest._addDigester.called); + }); + it('prepares Content-Digest', function () { + req.getHeader.onCall(3).returns(`sha-256=:${digestBase64.sha256}:, sha-512=:${digestBase64.sha512}:`); + Digest.establishRequestedDigesters(req, res); + assert(Digest._addDigester.called); + }); + }); // establishRequestedDigesters + +}); // Digest \ No newline at end of file diff --git a/test/lib/dingus.js b/test/lib/dingus.js index 2c94c76..0e4f43c 100644 --- a/test/lib/dingus.js +++ b/test/lib/dingus.js @@ -5,7 +5,8 @@ const sinon = require('sinon'); const fs = require('node:fs'); const Dingus = require('../../lib/dingus'); -const { DingusError, RouterNoMethodError } = require('../../lib/errors'); +const { DingusError, RouterNoMethodError, ResponseError } = require('../../lib/errors'); +const Digest = require('../../lib/digest'); const Enum = require('../../lib/enum'); const noExpectedException = 'did not get expected exception'; @@ -271,61 +272,6 @@ describe('Dingus', function () { }); }); // ingestCookie - describe('establishRequestedDigesters', function () { - let req, res; - beforeEach(function () { - sinon.stub(Dingus, 'addDigest'); - req = { - getHeader: sinon.stub(), - }; - }); - it('does nothing if no header', function () { - Dingus.establishRequestedDigesters(req, res); - assert(Dingus.addDigest.notCalled); - }); - it('adds digest if wanted', function () { - req.getHeader.returns('SHA-512, sha-256, sha, md5'); - Dingus.establishRequestedDigesters(req, res); - assert(Dingus.addDigest.called); - }); - it('does not add unknown', function () { - req.getHeader.returns('FLARP-512, baz'); - Dingus.establishRequestedDigesters(req, res); - assert(Dingus.addDigest.notCalled); - }); - }); // establishRequestedDigesters - - describe('addDigest', function () { - let res, end, write; - beforeEach(function () { - end = sinon.stub(); - write = sinon.stub(); - res = { - end, - write, - setHeader: sinon.stub(), - }; - }); - it('adds default digest', function () { - Dingus.addDigest(res); - res.write('data'); - assert(write.called); - res.end('more data'); - assert(end.called); - assert(res.setHeader.called); - }); - it('adds supported additional digests', function () { - Dingus.addDigest(res); - Dingus.addDigest(res, ['sha512', 'flarp', 'md5']); - res.write('data'); - res.end('done'); - assert.deepStrictEqual(res.setHeader.args[0], [ - 'Digest', - 'SHA-256=oAqSZv8qrvuB+ExN0cGS7xqj7Hp+neAOoX+ACn2eivs=,SHA-512=JlLyKzQd5TMyISTm0c5QMmDJ7mfoEhPHhCl8F8CfrstIJZFyLbYBp2nRDs7gyEMMFRW61tWG5O5zkl7JiUO03Q==,MD5=VkiaPKvCHXfHzfc+W0eDyQ==', - ]); - }); - }); // addDigest - describe('getRequestContentType', function () { let req; beforeEach(function () { @@ -832,39 +778,82 @@ describe('Dingus', function () { }); // bodyData describe('ingestBody', function () { + let req, res, ctx; + beforeEach(function () { + req = {}; + res = {}; + ctx = {}; + sinon.stub(dingus, 'bodyData'); + sinon.stub(Dingus, 'getRequestContentType'); + }); it('ingests json', async function () { - const req = {}; - const res = {}; - const ctx = {}; - sinon.stub(dingus, 'bodyData').resolves('{"foo":"bar"}'); - sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson); + dingus.bodyData.resolves('{"foo":"bar"}'); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); await dingus.ingestBody(req, res, ctx); assert.deepStrictEqual(ctx.parsedBody, { foo: 'bar' }); assert.deepStrictEqual(ctx.rawBody, undefined); }); it('persists rawBody', async function () { - const req = {}; - const res = {}; - const ctx = {}; const body = '{"foo":"bar"}'; - sinon.stub(dingus, 'bodyData').resolves(body); - sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson); + dingus.bodyData.resolves(body); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); await dingus.ingestBody(req, res, ctx, { persistRawBody: true }); assert.deepStrictEqual(ctx.parsedBody, { foo: 'bar' }); assert.deepStrictEqual(ctx.rawBody, body); }); it('skips parsing empty body', async function () { - const req = {}; - const res = {}; - const ctx = {}; const body = ''; - sinon.stub(dingus, 'bodyData').resolves(body); - sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson); + dingus.bodyData.resolves(body); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); sinon.spy(dingus, 'parseBody'); await dingus.ingestBody(req, res, ctx, { parseEmptyBody: false }); assert.deepStrictEqual(ctx.parsedBody, undefined); assert(dingus.parseBody.notCalled); }); + it('validates digests', async function () { + dingus.bodyData.resolves('{"foo":"bar"}'); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); + req[Digest.kDigests] = { + md5: Buffer.from('m7WPJhkuS6APAeLnsTa72A==', 'base64'), + sha256: Buffer.from('eji/gfOD9pQzrW6QDTWz4jhVk/dqe3q11DVbi6Qe4ks=', 'base64'), + }; + req[Digest.kDigesters] = { + md5: { base64: 'm7WPJhkuS6APAeLnsTa72A==' }, + sha256: { base64: 'eji/gfOD9pQzrW6QDTWz4jhVk/dqe3q11DVbi6Qe4ks=' }, + }; + await dingus.ingestBody(req, res, ctx); + assert.deepStrictEqual(ctx.integrity, { md5: true, sha256: true }); + }); + it('throws on invalid digest', async function () { + dingus.bodyData.resolves('{"foo":"bar"}'); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); + req[Digest.kDigests] = { + md5: Buffer.from('1B2M2Y8AsgTpgAmY7PhCfg==', 'base64'), + sha256: Buffer.from('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 'base64'), + }; + req[Digest.kDigesters] = { + md5: { base64: 'm7WPJhkuS6APAeLnsTa72A==' }, + sha256: { base64: 'eji/gfOD9pQzrW6QDTWz4jhVk/dqe3q11DVbi6Qe4ks=' }, + }; + await assert.rejects(() => dingus.ingestBody(req, res, ctx), ResponseError); + }); + it('covers digest options', async function () { + dingus.bodyData.resolves('{"foo":"bar"}'); + Dingus.getRequestContentType.returns(Enum.ContentType.ApplicationJson); + req[Digest.kDigests] = { + md5: Buffer.from('1B2M2Y8AsgTpgAmY7PhCfg==', 'base64'), + sha256: Buffer.from('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 'base64'), + }; + req[Digest.kDigesters] = { + md5: { base64: 'm7WPJhkuS6APAeLnsTa72A==' }, + sha256: { base64: 'eji/gfOD9pQzrW6QDTWz4jhVk/dqe3q11DVbi6Qe4ks=' }, + }; + const options = { + persistDigests: false, + validateDigests: false, + }; + await dingus.ingestBody(req, res, ctx, options); + }); }); // ingestBody describe('setResponseType', function () {