The override-token system in this codebase exists to let an attorney bypass a safety guard when they have a specific reason to do so. Mint a token for a specific operation, save it as an environment variable, and the corresponding guard check accepts it for that one call. The token is HMAC-signed against the operation kind and params, with a random nonce, and single-use-enforced via a redemption log.

The design is solid. The cryptography is solid. The single-use enforcement is solid. The one thing that turned out not to be solid was the canonicalization function that prepares the input for the HMAC.

function canonicalize(kind, params, nonce) {
  const safeParams = {};
  for (const k of Object.keys(params || {}).sort()) {
    if (params[k] !== null && params[k] !== undefined) {
      safeParams[k] = String(params[k]);
    }
  }
  return JSON.stringify({ kind: String(kind), params: safeParams, nonce: nonce || '' });
}

Top-level keys get sorted, which is correct. Null and undefined values get dropped, which is fine. Every other value gets passed through String(), which is where the trouble starts.

For primitives, String() is benign and predictable. String(123) is "123", String("abc") is "abc", String(true) is "true". Two identical primitives produce identical canonical forms.

For objects, String({a: 1}) is "[object Object]". So is String({b: 999}). So is String({applicant_id: 100, case_file_id: 200}). Every object stringifies to the same six-word phrase, regardless of contents. When that phrase becomes the value associated with a key in the canonical form, the canonical form for two completely different nested objects ends up identical:

// Token minted for this operation:
canonicalize('upload', {meta: {applicant_id: 100}}, 'nonce-xyz')
// → '{"kind":"upload","params":{"meta":"[object Object]"},"nonce":"nonce-xyz"}'

// Token verifies against this completely different operation:
canonicalize('upload', {meta: {applicant_id: 999, case_file_id: 999}}, 'nonce-xyz')
// → '{"kind":"upload","params":{"meta":"[object Object]"},"nonce":"nonce-xyz"}'

// Same canonical form. Same MAC. Token verifies. Operation is approved.

A probe confirmed this: token issued for {meta: {applicant_id: 100}} was accepted by verifyToken for {meta: {applicant_id: 999, case_file_id: 999}}. For {meta: {whatever: 'anything', x: [1,2,3]}}. For any structured value at the meta key, as long as it was an object.

How bad is this?

That depends on whether anyone passes nested objects to the override system. In this codebase, today, nobody does. All real callers (the binding-guard overrides in merus-fetch) pass scalar params: {task_id: 123}, {upload_id: 100, case_file_id: 200}, {message_id: 'M-456'}. Every param value is a string or a number. No object values means no collision.

So the bug isn't currently exploitable. But the contract is unsafe in a specific direction: every future override kind that someone adds is potentially exploitable. The function looks like it accepts arbitrary params (it doesn't validate or document the scalar-only assumption). If a future contributor adds an override that takes a metadata object — a perfectly reasonable thing to do — the collision becomes live without anyone noticing.

This is the same shape as the bug from a previous release: a function quietly coerces structured data into a string and the caller has no way to know the structure was destroyed. The "silently work for the common case, silently fail for the boundary case" pattern.

The fix without breaking existing tokens

The naive fix is to JSON-stringify each value instead of String-coercing it. That works for the collision, but it changes the canonical form for every previously-minted token: String(123) is "123", but JSON.stringify(123) is 123 (a number, not a string). Every existing scalar token in the wild would stop verifying.

The careful fix preserves String() behavior for primitives and only changes behavior for objects and arrays:

function _detValue(v) {
  if (v === null || v === undefined) return v;
  if (typeof v !== 'object') return String(v);   // primitives: same as before
  if (Array.isArray(v)) return v.map(_detValue); // arrays: per-element recurse
  const out = {};
  for (const k of Object.keys(v).sort()) {       // objects: sort keys recursively
    if (v[k] !== undefined) out[k] = _detValue(v[k]);
  }
  return out;
}

function canonicalize(kind, params, nonce) {
  const safeParams = {};
  for (const k of Object.keys(params || {}).sort()) {
    if (params[k] !== null && params[k] !== undefined) {
      safeParams[k] = _detValue(params[k]);
    }
  }
  return JSON.stringify({ kind: String(kind), params: safeParams, nonce: nonce || '' });
}

Now {meta: {a: 1}} canonicalizes to {"params":{"meta":{"a":"1"}}, ...} and {meta: {b: 1}} canonicalizes to {"params":{"meta":{"b":"1"}}, ...} — different strings, different MACs, no collision. But {task_id: 123} still canonicalizes to {"params":{"task_id":"123"}, ...} exactly as before, so every existing scalar token still verifies.

The recursive key-sort matters: without it, {a: 1, b: 2} and {b: 2, a: 1} at any nesting depth would produce different canonical forms, which would break the "logical equivalence ⇒ MAC equivalence" property. Real callers depend on top-level key reordering being normalized; nested reordering should follow the same rule.

What the probe tested

The probe round on the override module exercised 18 boundary cases across mint, verify, and the redemption path. The nested-object collision was the headline finding. Two related but lesser issues showed up in the same probe:

The other 13 boundary cases either confirmed designed-in behavior (timing-safe comparison works, tampered tokens rejected, wrong kind rejected, single-use enforcement holds) or didn't surface issues (very large params, special chars in kind, prototype pollution attempt).

The general lesson

Any HMAC-based system relies on canonicalization to map "logically equivalent inputs" onto "byte-identical strings." The canonicalization function is the security boundary, not the HMAC itself. The HMAC will faithfully compute over whatever bytes it gets — if two different logical inputs produce the same bytes, you have a collision regardless of how strong the hash is.

The temptation to use String(value) as a one-liner canonicalization is real because it usually works. It works for everything except objects, arrays, and certain edge cases (Symbol, BigInt). Those exceptions are exactly the ones a future contributor might add without realizing they break the contract.

The version on the install URL is 4.5.93.