rework digest handling
authorJustin Wind <justin.wind+git@gmail.com>
Sun, 3 Aug 2025 22:07:15 +0000 (15:07 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sun, 3 Aug 2025 22:07:15 +0000 (15:07 -0700)
index.js
lib/common.js
lib/content-negotiation.js
lib/digest.js [new file with mode: 0644]
lib/dingus.js
lib/enum.js
test/lib/common.js
test/lib/content-negotiation.js
test/lib/digest.js [new file with mode: 0644]
test/lib/dingus.js

index dcf66e8f32f759f80d5c111c382f4837a37c8200..9ae0059a1dd9b806bf631a39eb9bed5744734bdb 100644 (file)
--- 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,
index 4e4fd4ebd93ba3f0813b31f06b0d10b1d3d9efbd..109d4c3994ee826ee28b5db6314293121ba2f83c 100644 (file)
@@ -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,
index b941d892d4bb85ded91e1f861f7445a7d46d0783..7ea7e8a65ba2dd7c835c50222d577796a56ba538 100644 (file)
@@ -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 (file)
index 0000000..dcae27c
--- /dev/null
@@ -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<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
index ac91038a7f1ed7424119ddf88831d07d098439f1..adbcf019908258e26b7fd9ac69337728534d16a4 100644 (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');
@@ -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);
     }
index 4479826bd85119d60907bcb08a6607045bac416b..02337f7dcd6127d03679a5c8be9d1395549aa67a 100644 (file)
@@ -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',
index e1e47c45cdeafb15553e53d9834d59b1303e6903..57ab8eaa0bd4e1fc7a13ee1a2db2a18cef6eaea0 100644 (file)
@@ -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
index 1185efd7b92fbfc021d516d5cc93c65ebb708724..238f457ee7f6a88e681dde034d56673dbf4cfdc3 100644 (file)
@@ -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 (file)
index 0000000..400def5
--- /dev/null
@@ -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
index 2c94c767008e222a8f90760bb48f29ea67b5e243..0e4f43c6d93b86d43b8927ecc93de163c8c5bafd 100644 (file)
@@ -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 () {