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,
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,
isClientCached,
mergeDeep,
mergeEnum,
+ objectRemap,
pick,
requestId,
setOptions,
}
- // 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;
--- /dev/null
+'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<string, Digester>} 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<string, Buffer>} 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
*/
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');
*/
class Dingus {
- static kResponseDigesters = Symbol.for('squeep-dingus-response-digesters');
/**
* @param {object} logger console-like object which implements logging methods
}
- /**
- * 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);
+ Digest.establishRequestedDigesters(req, res);
}
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 });
});
req.on('end', () => {
const bodyBuffer = Buffer.concat(body);
+ Digest.finishDigesters(req);
resolve(toString ? bodyBuffer.toString() : bodyBuffer);
});
req.on('error', (e) => {
/**
* 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
* @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);
}
AcceptEncoding: 'Accept-Encoding',
Allow: 'Allow',
CacheControl: 'Cache-Control',
+ ContentDigest: 'Content-Digest',
ContentEncoding: 'Content-Encoding',
ContentLength: 'Content-Length',
ContentType: 'Content-Type',
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',
});
}); // 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
});
}); // 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
--- /dev/null
+'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
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';
});
}); // 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 () {
}); // 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 () {