## [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
---
-[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
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;
*/
require('./patches');
+const crypto = require('node:crypto');
const { promises: fsPromises } = require('node:fs');
const path = require('node:path');
const querystring = require('node:querystring');
*/
class Dingus {
+ static kResponseDigesters = Symbol.for('squeep-dingus-response-digesters');
+
/**
* @param {object} logger console-like object which implements logging methods
* @param {DingusOptions} options options
}
+ /**
+ * 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.
this.constructor.tagContext(req, res, ctx);
this.clientAddressContext(req, res, ctx);
this.constructor.ingestCookie(req, res, ctx);
+ this.constructor.establishRequestedDigesters(req, res);
}
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',
ContentLength: 'Content-Length',
ContentType: 'Content-Type',
Cookie: 'Cookie',
+ Digest: 'Digest',
ETag: 'ETag',
IfModifiedSince: 'If-Modified-Since',
IfNoneMatch: 'If-None-Match',
RequestId: 'Request-ID',
SetCookie: 'Set-Cookie',
Vary: 'Vary',
+ WantDigest: 'Want-Digest',
XCorrelationId: 'X-Correlation-ID',
XForwardedFor: 'X-Forwarded-For',
XForwardedProto: 'X-Forwarded-Proto',
module.exports = {
ContentType,
+ DigestAlgorithm,
EncodingType,
EncodingTypeSuffix,
ErrorResponse: ErrorResponseProxy,
});
}); // 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
});
}); // 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 () {