From 0795a90276a93cc9208a0438f783842abcf540f7 Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Mon, 28 Jul 2025 14:51:40 -0700 Subject: [PATCH] Digest headers support --- CHANGELOG.md | 6 +++- lib/content-negotiation.js | 22 ++++++++++++ lib/dingus.js | 63 +++++++++++++++++++++++++++++++++ lib/enum.js | 11 ++++++ test/lib/content-negotiation.js | 15 ++++++++ test/lib/dingus.js | 55 ++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0522e29..8a60937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ Releases and notable changes to this project are documented here. ## [Unreleased] +## [v2.2.0] - TBD + - support naming route paths, and rendering those named paths with parameter substitution as relative, absolute, or external links - helper for defining routes for static files (opinionated) - send Allow header on 405 Method Not Allowed responses - reference to Dingus added to context +- RFC3230 digest header support ## [v2.1.3] - 2025-03-28 @@ -129,7 +132,8 @@ Releases and notable changes to this project are documented here. --- -[Unreleased]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=HEAD;hp=v2.1.3 +[Unreleased]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=HEAD;hp=v2.2.0 +[v2.2.0]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=v2.2.0;hp=v2.1.3 [v2.1.3]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=v2.1.3;hp=v2.1.2 [v2.1.2]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=v2.1.2;hp=v2.1.1 [v2.1.1]: https://git.squeep.com/?p=squeep-api-dingus;a=commitdiff;h=v2.1.1;hp=v2.1.0 diff --git a/lib/content-negotiation.js b/lib/content-negotiation.js index fcf99ad..b941d89 100644 --- a/lib/content-negotiation.js +++ b/lib/content-negotiation.js @@ -169,6 +169,28 @@ class ContentNegotiation { return allowedClauses.map((enc) => enc.type); } + + // 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/dingus.js b/lib/dingus.js index 9ee9cf8..ac91038 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -10,6 +10,7 @@ */ require('./patches'); +const crypto = require('node:crypto'); const { promises: fsPromises } = require('node:fs'); const path = require('node:path'); const querystring = require('node:querystring'); @@ -62,6 +63,8 @@ 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 * @param {DingusOptions} options options @@ -389,6 +392,65 @@ 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. @@ -400,6 +462,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); } diff --git a/lib/enum.js b/lib/enum.js index 77d2c02..4479826 100644 --- a/lib/enum.js +++ b/lib/enum.js @@ -7,6 +7,14 @@ const ContentType = { ApplicationJson: 'application/json', }; +// Supported RFC3230 algorithms +const DigestAlgorithm = { + 'sha256': 'SHA-256', + 'sha512': 'SHA-512', + 'sha1': 'SHA', + 'md5': 'MD5', +}; + // Supported encoding types const EncodingType = { Brotli: 'br', @@ -208,6 +216,7 @@ const Header = { ContentLength: 'Content-Length', ContentType: 'Content-Type', Cookie: 'Cookie', + Digest: 'Digest', ETag: 'ETag', IfModifiedSince: 'If-Modified-Since', IfNoneMatch: 'If-None-Match', @@ -216,6 +225,7 @@ const Header = { RequestId: 'Request-ID', SetCookie: 'Set-Cookie', Vary: 'Vary', + WantDigest: 'Want-Digest', XCorrelationId: 'X-Correlation-ID', XForwardedFor: 'X-Forwarded-For', XForwardedProto: 'X-Forwarded-Proto', @@ -225,6 +235,7 @@ const Header = { module.exports = { ContentType, + DigestAlgorithm, EncodingType, EncodingTypeSuffix, ErrorResponse: ErrorResponseProxy, diff --git a/test/lib/content-negotiation.js b/test/lib/content-negotiation.js index 238f457..1185efd 100644 --- a/test/lib/content-negotiation.js +++ b/test/lib/content-negotiation.js @@ -201,5 +201,20 @@ 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/dingus.js b/test/lib/dingus.js index 1f9266e..2c94c76 100644 --- a/test/lib/dingus.js +++ b/test/lib/dingus.js @@ -271,6 +271,61 @@ 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 () { -- 2.49.1