Digest headers support
authorJustin Wind <justin.wind+git@gmail.com>
Mon, 28 Jul 2025 21:51:40 +0000 (14:51 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Mon, 28 Jul 2025 21:51:40 +0000 (14:51 -0700)
CHANGELOG.md
lib/content-negotiation.js
lib/dingus.js
lib/enum.js
test/lib/content-negotiation.js
test/lib/dingus.js

index 0522e2938963986b401c251389610f2bdd4f1164..8a60937a0ad92a975eaf91dae19c958d4465dd82 100644 (file)
@@ -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
index fcf99adafcdb26f8e726a2203c9b4cfeca363346..b941d892d4bb85ded91e1f861f7445a7d46d0783 100644 (file)
@@ -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;
index 9ee9cf8608e1796a77d026f1f479f302698d814f..ac91038a7f1ed7424119ddf88831d07d098439f1 100644 (file)
@@ -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);
   }
 
 
index 77d2c02fd5179be1b6c38824c3b3483e67ae87d5..4479826bd85119d60907bcb08a6607045bac416b 100644 (file)
@@ -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,
index 238f457ee7f6a88e681dde034d56673dbf4cfdc3..1185efd7b92fbfc021d516d5cc93c65ebb708724 100644 (file)
@@ -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
 
index 1f9266e76c658fa8eacae9901dcb964e3ce25496..2c94c767008e222a8f90760bb48f29ea67b5e243 100644 (file)
@@ -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 () {