From 0a912d421e36cecd84ace4db2b5dc0809d2836eb Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 07:25:34 -0700 Subject: [PATCH] feat(functions): add bounded request safety primitives --- GITHUB_ISSUES.md | 10 +- GITHUB_ISSUE_SLICES.md | 2 +- IMPLEMENTATION_PLAN.md | 4 +- SECURITY.md | 1 + STRIPE_COMMERCE_DESIGN.md | 13 + functions/requestValidation.js | 838 ++++++++++++++++++++++++++ functions/requestValidation.test.js | 886 ++++++++++++++++++++++++++++ functions/safeLogging.js | 118 ++++ functions/safeLogging.test.js | 205 +++++++ 9 files changed, 2073 insertions(+), 4 deletions(-) create mode 100644 functions/requestValidation.js create mode 100644 functions/requestValidation.test.js create mode 100644 functions/safeLogging.js create mode 100644 functions/safeLogging.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 22d9b526..67b2d4a0 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -60,7 +60,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 8 | OAUTH-001 | Make Strava token lifecycle server-only, transactional, and auditable | P0 | M | represented by live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); proposed/unclaimed | SAFETY-001/#99; SEC-001 and AUTH-003 for A; staged deferral/cutover with ABUSE-001A for C | | 9 | AUTH-003 | Introduce scoped admin capabilities, MFA, and recent authentication | P1 | L | proposed | AUTH-001, AUTH-002, SEC-001 | | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | ready | CI-001 baseline | -| 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | ready | ABUSE-001 interface agreed | +| 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | partial: PAY-001A source/tests tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); B/C/D and endpoint adoption open | ABUSE-001 interface agreed | | 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | source/tests complete under open GitHub issue #102; provider inventory and deployment remain owner action | PAY-001 for any future discount contract | | 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | ready after PAY-001 | CONFIG-001, PAY-001 | | 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | PAY-003A source merged in #101; PAY-003B/C remain open; not deployed | CONFIG-001, PAY-001/PAY-002 target contract | @@ -533,9 +533,15 @@ App Check is not authentication. Do not remove Auth/capability checks or rely on ## PAY-001 — Add strict request schemas and immutable monetary snapshots **Labels:** `priority:P0`, `type:security`, `area:stripe`, `area:race`, `area:shop`, `size:L` -**Status:** Ready +**Status:** Partial. PAY-001A source/tests are tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). PAY-001B/C/D, endpoint adoption, snapshots, deployment, and live behavior remain open. **Depends on:** ABUSE-001 interface agreed +### Current atomic boundary + +PAY-001A adds dependency-free strict-root-object, root-budget, bounded array/string, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative-email, fixed-error, and closed-enum safe-log primitives. Unknown configured root keys reject; nested values remain data-only/budgeted until endpoint-owned exact schemas validate them. The libraries return new immutable values, contain no Firebase, Stripe, network, logger, clock, or random dependency, and make no such call themselves. A supplied array item parser must be trusted, pure, synchronous, and endpoint-owned. Exact review, merge, and hosted-CI status belong to [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). + +No current endpoint imports these helpers. Race, merchandise, lookup, refund, cancellation, substitution, role, tracking, export, and dynamic custom-field schemas remain **NOT AVAILABLE YET** under PAY-001B/C/D. PAY-001A also does not create immutable price/waiver snapshots, deploy Firebase, configure Stripe, or prove production protection. + ### Problem Public/admin Functions validate only a few required strings. Names, phones, dates, notes, tracking values, custom fields, arrays, URLs, amounts, and nested payload size are weakly bounded. Event-required custom fields and allowed options are enforced only in HTML. Monetary records store one list amount but not an immutable expected/actual breakdown. diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 093f0520..5e370e86 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -55,7 +55,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | CONFIG-001B1 | source/tests tracked in live [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151); CONFIG-001A; not deployed/configured | Require an exact deploy-time ceiling; false denies new commerce before Firestore. Commands passing it and every refund use a fresh server-only global/domain/resource admission read. Keep webhook/mail independent; missing controls and resource flags mean disabled. | Paid/free/volunteer, merchandise, comp, late-registration, and refund allow/deny tests prove fixed failures and no command side effect; Rules tests deny every browser role; continuity and race-boundary tests pass. | | CONFIG-001B2 | blocked owner/operator; B1, AUTH-003/ADMIN-001, PAY-002/PAY-004/PAY-005, CI-001 | Add a recent-auth capability-protected writer, append-only audit, active-provider-object recovery, protected deployment, and private disable/restore drill. | Named owners safely disable and restore synthetic staging commerce without code changes; in-flight and existing Stripe objects are reconciled; private provider/Firebase evidence is recorded. | | PROMO-001 | source/tests complete in [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102); provider inventory and deployment remain owner action; PAY-001A for future enablement | Disable promotion entry and automatic tax in both Session creators; require a complete Stripe breakdown; quarantine unknown or nonzero discount, tax, or shipping adjustments. | Exact Session payload assertions and signed zero/partial/full/malformed/failed/expired adjustment tests pass; pre-change Session/provider inventory and live verification remain open. | -| PAY-001A | ready; CI-001A | Add shared strict-object, bounds/depth, integer-cents/currency/date/URL/email primitives, safe errors, and redacted logging. | Prototype/deep/Unicode/money/unknown-key tests prove no external call. | +| PAY-001A | source/tests tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); CI-001A; no endpoint adoption/deployment | Add shared strict-object, root-budget, bounded array/string, integer-cents/currency/date/URL/email, fixed-error, and closed-enum safe-log primitives. | Node 20 prototype/proxy/deep/Unicode/money/unknown-key/log-canary tests prove immutable outputs and no external or logger call; exact merge/CI evidence belongs to #157. | | PAY-001B | proposed; PAY-001A | Apply race/volunteer/free schemas, validate server event custom fields/options, and persist price/waiver snapshots. | Positive/hostile dynamic-field and snapshot tests. | | PAY-001C | proposed; PAY-001A | Apply merchandise/variant/quantity schemas and immutable merchandise price snapshots. | Variant/quantity/money/payload tests. | | PAY-001D | proposed; PAY-001A, AUTH-003A | Apply bounded schemas to lookup/refund/cancel/substitute/late/comp/role/tracking/export commands. | Endpoint matrix with hostile/boundary/no-side-effect tests. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 03294cdd..8cea0248 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -108,6 +108,8 @@ Exit gate: - Sensitive callables enforce App Check at the Firebase runtime. - All public/admin request shapes are strict, bounded, server-validated, and covered by hostile-input tests. +**Current partial boundary:** PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). It provides pure strict-object, shared-budget, scalar, fixed-error, and safe-log primitives with synthetic hostile-input tests. No current endpoint imports them, so PAY-001B/C/D endpoint schemas, immutable monetary snapshots, deployment, and live protection remain **NOT AVAILABLE YET**. + ### Phase 2 — Build the payment integrity core **Issues:** PROMO-001, PAY-002, PAY-003, RACE-001, MERCH-001 @@ -211,7 +213,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 3. **SEC-001 — Firestore trust boundaries:** #100 merged through [PR #123](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/123) at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. Source and 295 Rules tests remove recursive browser-admin authority. Firebase deployment explicitly skipped, so deployed/live Rules remain unproven under #105. 4. **PROMO-001 — fail-safe monetary policy:** [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) disables promotion entry and automatic tax in both current creators, adds exact payload tests, and quarantines unknown or nonzero discount/tax/shipping adjustments on success, failure, and expiry. The private pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain owner actions; no discount, tax, or shipping feature is authorized. 5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. -6. **Then:** PAY-001/PAY-002 so checkout creation, refunds, and every later handler share strict schemas and idempotent state. +6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-001B/C/D must still wire exact endpoint schemas and immutable snapshots before PAY-002 can make checkout creation, refunds, and later handlers idempotent. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. Why this order: SAFETY-001 removes immediate accidental-production and callback hazards without schema impact. PAY-003 closes the most dangerous “unpaid looks paid” and replay gaps while establishing test fixtures. The subsequent trust-boundary and command/state work completes the surrounding architecture; no live launch is authorized by the partial webhook patch alone. diff --git a/SECURITY.md b/SECURITY.md index 4fdb6abc..f068f6e9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,6 +95,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | +| RISK-017 | PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). The source slice adds dependency-free strict-root-object, root-budget, bounded string/array, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative email, fixed-error, and closed-enum safe-log primitives. Synthetic hostile-input tests prove deep immutable copies and reject prototypes, accessors, and unknown configured root keys. The libraries contain no network, Firebase, Stripe, logger, clock, or random dependency and make no such call themselves; a supplied array item parser must be trusted, pure, and synchronous. | This library is not imported by an endpoint and is not deployed. PAY-001B/C/D must define exact race, merchandise, lookup, refund, admin, nested-object, and custom-field schemas; apply smaller business limits before every write/provider call; store immutable schema/price snapshots; and add endpoint no-side-effect tests. Safe-log primitives do not replace approved observability purpose, retention, access, transport, or provider controls. | | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | | RISK-009 | #99 preserves same-origin path, query, and fragment state through the Pages fallback, clears temporary state, rejects unsafe targets, and suppresses monitoring startup on initial capability callbacks. #126 adds the standalone suite as the named blocking `Run SPA callback safety tests` hosted step. | Keep exact hosted evidence on #126; verify exact website publication and later run approved test-mode Stripe/Strava callbacks; WEB-001 still owns controlled hosting. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index a40db597..5f13324e 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -101,6 +101,19 @@ The backend should remain correct if a delayed method appears accidentally: - Keep Stripe IDs as references, not proof: Session, PaymentIntent, Charge, Refund, Product, Price, and Event IDs each have different semantics. - Do not put PII, waiver text, emergency contacts, or secrets in Stripe metadata. Use opaque local IDs and a schema version. +### Shared request-safety primitives (PAY-001A) + +PAY-001A is tracked in live issue [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). It adds two pure CommonJS libraries for later server endpoint work: + +- `requestValidation` accepts an exact root plain object with own data properties, rejects unknown configured root keys and dangerous keys anywhere, returns a new deeply frozen value, and applies one technical budget across a strict-object tree. Nested values are data-only and budgeted; every nested object still needs an endpoint-owned exact schema. The hard ceilings are depth 6, 100 object/array entries, 50 items per array, 128 code points/512 bytes per key, 2,000 code points/8,192 bytes per string, and 64 KiB serialized. Every endpoint must define a smaller business limit where appropriate. +- Its scalar helpers accept only nonnegative integer cents (maximum 100,000,000 technical cents, with a lower endpoint limit expected), launch currency `usd`, canonical calendar dates, canonical HTTPS DNS URLs, explicitly enabled canonical test/development loopback URLs, and conservative bounded ASCII email syntax. URL syntax validation is not an outbound-host authorization or SSRF allowlist. Email syntax is not proof of identity, membership, or delivery. +- `safeLogging` creates only a new frozen five-field projection—event, operation, outcome, code, and environment—from fixed low-cardinality values. It cannot copy request bodies, arbitrary keys, IDs, URLs, contact details, addresses, Stripe/OAuth material, or raw errors, and it does not call a logger. +- Failures expose one fixed message and a fixed reason category. Supplied values and provider details are not included. + +The libraries contain no Firebase, Stripe, provider, logger, network, clock, or random dependency and make no such call themselves. `parseBoundedArray` deliberately invokes a supplied item parser; that parser must be trusted, pure, synchronous, and endpoint-owned. + +These helpers are a source/test foundation only. No checkout, refund, admin, webhook, Firebase, or Stripe endpoint imports them in PAY-001A. Endpoint schemas, immutable business-price snapshots, deployment, and production behavior remain **NOT AVAILABLE YET** under PAY-001B/C/D and later payment issues. + ## 5. Catalog model ### Race pricing diff --git a/functions/requestValidation.js b/functions/requestValidation.js new file mode 100644 index 00000000..7d8df01f --- /dev/null +++ b/functions/requestValidation.js @@ -0,0 +1,838 @@ +const { isIP } = require('node:net'); +const { types: { isProxy } } = require('node:util'); + +const REQUEST_VALIDATION_MESSAGE = 'Request data is invalid'; +const REQUEST_VALIDATION_REASONS = Object.freeze({ + INVALID_OBJECT: 'invalid_object', + INVALID_FIELDS: 'invalid_fields', + INVALID_VALUE: 'invalid_value', + PAYLOAD_LIMIT: 'payload_limit', + INVALID_STRING: 'invalid_string', + INVALID_ARRAY: 'invalid_array', + INVALID_MONEY: 'invalid_money', + INVALID_CURRENCY: 'invalid_currency', + INVALID_DATE: 'invalid_date', + INVALID_URL: 'invalid_url', + INVALID_EMAIL: 'invalid_email', + SAFE_LOG_INVALID: 'safe_log_invalid', +}); + +const VALIDATION_REASON_SET = new Set(Object.values(REQUEST_VALIDATION_REASONS)); +const SAFE_VALIDATION_ERRORS = new WeakSet(); +const SAFE_CLONES = new WeakSet(); +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const STRICT_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/; + +// These are technical denial-of-service ceilings, not endpoint or product policy. +// Endpoint schemas may lower them, but may not raise them. +const DEFAULT_REQUEST_LIMITS = Object.freeze({ + maxDepth: 6, + maxEntries: 100, + maxArrayLength: 50, + maxKeyCodePoints: 128, + maxKeyBytes: 512, + maxStringCodePoints: 2000, + maxStringBytes: 8192, + maxSerializedBytes: 65536, +}); +const MAX_CENTS = 100000000; + +class RequestValidationError extends Error { + constructor(reason) { + super(REQUEST_VALIDATION_MESSAGE); + const safeReason = VALIDATION_REASON_SET.has(reason) + ? reason + : REQUEST_VALIDATION_REASONS.INVALID_VALUE; + Object.defineProperty(this, 'message', { + value: REQUEST_VALIDATION_MESSAGE, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'name', { + value: 'RequestValidationError', + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'reason', { + value: safeReason, + enumerable: false, + writable: false, + configurable: false, + }); + Error.captureStackTrace?.(this, RequestValidationError); + Object.freeze(this); + } +} + +function reject(reason) { + const error = new RequestValidationError(reason); + SAFE_VALIDATION_ERRORS.add(error); + throw error; +} + +function invalidOptions() { + throw new TypeError('Invalid validation options'); +} + +function isWellFormedString(value) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xD800 && code <= 0xDBFF) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xDC00 && next <= 0xDFFF)) return false; + index += 1; + } else if (code >= 0xDC00 && code <= 0xDFFF) { + return false; + } + } + return true; +} + +function codePointLength(value) { + let length = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xD800 && code <= 0xDBFF) index += 1; + length += 1; + } + return length; +} + +function ownDataEntries( + value, + reason, + maximumEntries = Number.MAX_SAFE_INTEGER, + expectedPrototype = Object.prototype, +) { + if (value === null || typeof value !== 'object' || isProxy(value)) reject(reason); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + reject(reason); + } + const trustedNullPrototype = expectedPrototype === Object.prototype + && prototype === null + && SAFE_CLONES.has(value); + if (prototype !== expectedPrototype && !trustedNullPrototype) reject(reason); + if (keys.length > maximumEntries) { + reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + } + + const entries = []; + for (const key of keys) { + if (typeof key !== 'string' || DANGEROUS_KEYS.has(key)) reject(reason); + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + reject(reason); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + reject(reason); + } + entries.push([key, descriptor.value]); + } + + // A polluted Object.prototype would otherwise look like an ordinary plain object. + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) reject(reason); + } + return entries; +} + +function ownArrayValues(value, reason, maximumLength = Number.MAX_SAFE_INTEGER) { + if (value === null || typeof value !== 'object' || isProxy(value)) reject(reason); + if (!Array.isArray(value)) reject(reason); + + let prototype; + try { + prototype = Object.getPrototypeOf(value); + } catch { + reject(reason); + } + if (prototype !== Array.prototype) reject(reason); + + let lengthDescriptor; + try { + lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + } catch { + reject(reason); + } + if (!lengthDescriptor + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0) { + reject(reason); + } + const length = lengthDescriptor.value; + if (length > maximumLength) reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + + let keys; + try { + keys = Reflect.ownKeys(value); + } catch { + reject(reason); + } + const descriptors = new Map(); + + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string') reject(reason); + const index = Number(key); + if (!Number.isSafeInteger(index) + || index < 0 + || index >= length + || String(index) !== key) { + reject(reason); + } + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + reject(reason); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + reject(reason); + } + descriptors.set(index, descriptor.value); + } + + if (descriptors.size !== length) reject(reason); + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) reject(reason); + } + const values = []; + for (let index = 0; index < length; index += 1) { + if (!descriptors.has(index)) reject(reason); + values.push(descriptors.get(index)); + } + return values; +} + +function readOptionsObject(value, allowedKeys) { + if (value === undefined) return new Map(); + if (value === null || typeof value !== 'object' || isProxy(value)) invalidOptions(); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + invalidOptions(); + } + if (prototype !== Object.prototype || keys.some((key) => typeof key !== 'string')) { + invalidOptions(); + } + + const result = new Map(); + for (const key of keys) { + if (!allowedKeys.has(key) || DANGEROUS_KEYS.has(key)) invalidOptions(); + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + invalidOptions(); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + invalidOptions(); + } + result.set(key, descriptor.value); + } + return result; +} + +function copyConfiguredKeys(value, label) { + if (value === null + || typeof value !== 'object' + || isProxy(value) + || !Array.isArray(value) + || Object.getPrototypeOf(value) !== Array.prototype) { + invalidOptions(); + } + const values = ownArrayValuesForOptions(value); + const seen = new Set(); + for (const key of values) { + if (typeof key !== 'string' + || !STRICT_FIELD_NAME.test(key) + || key.length > DEFAULT_REQUEST_LIMITS.maxKeyCodePoints + || DANGEROUS_KEYS.has(key) + || seen.has(key)) { + invalidOptions(); + } + seen.add(key); + } + return { label, values: Object.freeze([...values]), set: seen }; +} + +function ownArrayValuesForOptions(value) { + let keys; + try { + keys = Reflect.ownKeys(value); + } catch { + invalidOptions(); + } + const length = value.length; + const descriptors = new Map(); + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string') invalidOptions(); + const index = Number(key); + if (!Number.isSafeInteger(index) + || index < 0 + || index >= length + || String(index) !== key) { + invalidOptions(); + } + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + invalidOptions(); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + invalidOptions(); + } + descriptors.set(index, descriptor.value); + } + if (descriptors.size !== length) invalidOptions(); + return Array.from({ length }, (_unused, index) => descriptors.get(index)); +} + +function normalizeLimits(value) { + const allowed = new Set(Object.keys(DEFAULT_REQUEST_LIMITS)); + const options = readOptionsObject(value, allowed); + const limits = {}; + for (const [name, hardMaximum] of Object.entries(DEFAULT_REQUEST_LIMITS)) { + const candidate = options.has(name) ? options.get(name) : hardMaximum; + const minimum = name === 'maxDepth' ? 0 : 1; + if (!Number.isSafeInteger(candidate) + || candidate < minimum + || candidate > hardMaximum) { + invalidOptions(); + } + limits[name] = candidate; + } + return Object.freeze(limits); +} + +function addEntries(state, amount, limits) { + state.entries += amount; + if (state.entries > limits.maxEntries) { + reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + } +} + +function jsonStringByteLength(value) { + let bytes = 2; // Opening and closing quotation marks. + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5C) { + bytes += 2; + } else if ([0x08, 0x09, 0x0A, 0x0C, 0x0D].includes(code)) { + bytes += 2; + } else if (code <= 0x1F) { + bytes += 6; + } else if (code <= 0x7F) { + bytes += 1; + } else if (code <= 0x7FF) { + bytes += 2; + } else if (code >= 0xD800 && code <= 0xDBFF) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } + return bytes; +} + +function measureSerializedBytes(value, maximum) { + const state = { bytes: 0 }; + const add = (amount) => { + state.bytes += amount; + if (state.bytes > maximum) reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + }; + + const visit = (current) => { + if (typeof current === 'string') { + add(jsonStringByteLength(current)); + } else if (current === null) { + add(4); + } else if (typeof current === 'boolean') { + add(current ? 4 : 5); + } else if (typeof current === 'number') { + add(String(current).length); + } else if (Array.isArray(current)) { + const values = ownArrayValues(current, REQUEST_VALIDATION_REASONS.INVALID_VALUE); + add(2 + Math.max(0, values.length - 1)); + values.forEach(visit); + } else if (typeof current === 'object') { + const entries = ownDataEntries( + current, + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + Number.MAX_SAFE_INTEGER, + null, + ); + add(2 + Math.max(0, entries.length - 1)); + for (const [key, child] of entries) { + add(jsonStringByteLength(key) + 1); + visit(child); + } + } else { + reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + } + }; + + visit(value); + return state.bytes; +} + +function cloneBoundedValue(value, depth, state, limits, active, rootShape = null) { + if (rootShape !== null) { + if (value === null || typeof value !== 'object') { + reject(REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + } + if (isProxy(value) || Array.isArray(value)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + } + } + if (typeof value === 'string') { + if (value.length > limits.maxStringBytes + || value.length > limits.maxStringCodePoints * 2 + || !isWellFormedString(value) + || codePointLength(value) > limits.maxStringCodePoints + || Buffer.byteLength(value, 'utf8') > limits.maxStringBytes) { + reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + } + return value; + } + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + } + return value; + } + if (typeof value !== 'object') reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + if (depth > limits.maxDepth) reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + if (isProxy(value)) reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + if (active.has(value)) reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + + active.add(value); + try { + if (Array.isArray(value)) { + if (rootShape !== null) reject(REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + const remainingEntries = Math.max(0, limits.maxEntries - state.entries); + const values = ownArrayValues( + value, + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + Math.min(limits.maxArrayLength, remainingEntries), + ); + addEntries(state, values.length, limits); + const cloned = values.map((item) => ( + cloneBoundedValue(item, depth + 1, state, limits, active) + )); + SAFE_CLONES.add(cloned); + return Object.freeze(cloned); + } + + const entries = ownDataEntries( + value, + REQUEST_VALIDATION_REASONS.INVALID_OBJECT, + Math.max(0, limits.maxEntries - state.entries), + ); + for (const [key] of entries) { + if (key.length > limits.maxKeyBytes + || key.length > limits.maxKeyCodePoints * 2 + || !isWellFormedString(key) + || codePointLength(key) > limits.maxKeyCodePoints + || Buffer.byteLength(key, 'utf8') > limits.maxKeyBytes) { + reject(REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT); + } + } + const sortedEntries = [...entries].sort(([left], [right]) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); + if (rootShape) { + const present = new Set(sortedEntries.map(([key]) => key)); + if (sortedEntries.some(([key]) => !rootShape.allowed.has(key)) + || rootShape.required.values.some((key) => !present.has(key))) { + reject(REQUEST_VALIDATION_REASONS.INVALID_FIELDS); + } + } + addEntries(state, sortedEntries.length, limits); + const cloned = Object.create(null); + for (const [key, child] of sortedEntries) { + Object.defineProperty(cloned, key, { + value: cloneBoundedValue(child, depth + 1, state, limits, active), + enumerable: true, + writable: true, + configurable: true, + }); + } + SAFE_CLONES.add(cloned); + return Object.freeze(cloned); + } finally { + active.delete(value); + } +} + +/** + * Validate one exact object boundary and return a new recursively frozen JSON-like clone. + * Nested object schemas still need their own exact-key validation in endpoint children. + */ +function parseStrictObject(value, options = {}) { + const optionValues = readOptionsObject( + options, + new Set(['requiredKeys', 'optionalKeys', 'limits']), + ); + const required = copyConfiguredKeys( + optionValues.has('requiredKeys') ? optionValues.get('requiredKeys') : [], + 'required', + ); + const optional = copyConfiguredKeys( + optionValues.has('optionalKeys') ? optionValues.get('optionalKeys') : [], + 'optional', + ); + if (required.values.some((key) => optional.set.has(key))) invalidOptions(); + const allowed = new Set([...required.values, ...optional.values]); + const limits = normalizeLimits(optionValues.get('limits')); + const state = { entries: 0 }; + const result = cloneBoundedValue( + value, + 0, + state, + limits, + new WeakSet(), + { required, allowed }, + ); + measureSerializedBytes(result, limits.maxSerializedBytes); + return result; +} + +/** + * Bound and freeze raw items before passing each clone to a trusted, pure, + * synchronous item parser. The parser output is independently bounded/frozen. + */ +function parseBoundedArray(value, options = {}) { + const optionValues = readOptionsObject(options, new Set(['maxItems', 'itemParser'])); + const maxItems = optionValues.get('maxItems'); + const itemParser = optionValues.get('itemParser'); + if (!Number.isSafeInteger(maxItems) + || maxItems < 0 + || maxItems > DEFAULT_REQUEST_LIMITS.maxArrayLength + || typeof itemParser !== 'function') { + invalidOptions(); + } + const values = ownArrayValues( + value, + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + maxItems, + ); + + const rawState = { entries: values.length }; + const rawActive = new WeakSet(); + const boundedInputs = values.map((item) => ( + cloneBoundedValue(item, 0, rawState, DEFAULT_REQUEST_LIMITS, rawActive) + )); + measureSerializedBytes(boundedInputs, DEFAULT_REQUEST_LIMITS.maxSerializedBytes); + const outputState = { entries: values.length }; + const outputActive = new WeakSet(); + const parsed = boundedInputs.map((item, index) => { + let result; + try { + result = itemParser(item, index); + } catch (error) { + if (SAFE_VALIDATION_ERRORS.has(error)) reject(error.reason); + reject(REQUEST_VALIDATION_REASONS.INVALID_VALUE); + } + return cloneBoundedValue(result, 0, outputState, DEFAULT_REQUEST_LIMITS, outputActive); + }); + const frozen = Object.freeze(parsed); + measureSerializedBytes(frozen, DEFAULT_REQUEST_LIMITS.maxSerializedBytes); + return frozen; +} + +function parseBoundedString(value, options = {}) { + const optionValues = readOptionsObject( + options, + new Set(['maxCodePoints', 'maxBytes', 'normalize', 'trim']), + ); + const maxCodePoints = optionValues.get('maxCodePoints'); + const maxBytes = optionValues.get('maxBytes'); + const normalize = optionValues.has('normalize') ? optionValues.get('normalize') : 'NFC'; + const trim = optionValues.has('trim') ? optionValues.get('trim') : false; + if (!Number.isSafeInteger(maxCodePoints) + || maxCodePoints < 0 + || maxCodePoints > DEFAULT_REQUEST_LIMITS.maxStringCodePoints + || !Number.isSafeInteger(maxBytes) + || maxBytes < 0 + || maxBytes > DEFAULT_REQUEST_LIMITS.maxStringBytes + || (normalize !== 'NFC' && normalize !== false) + || typeof trim !== 'boolean') { + invalidOptions(); + } + if (typeof value !== 'string' + || value.length > maxBytes + || value.length > maxCodePoints * 2 + || !isWellFormedString(value) + || codePointLength(value) > maxCodePoints + || Buffer.byteLength(value, 'utf8') > maxBytes) { + reject(REQUEST_VALIDATION_REASONS.INVALID_STRING); + } + + let parsed = normalize === false ? value : value.normalize(normalize); + if (trim) parsed = parsed.trim(); + if (!isWellFormedString(parsed) + || codePointLength(parsed) > maxCodePoints + || Buffer.byteLength(parsed, 'utf8') > maxBytes) { + reject(REQUEST_VALIDATION_REASONS.INVALID_STRING); + } + return parsed; +} + +function parseNonnegativeCents(value, options = {}) { + const optionValues = readOptionsObject(options, new Set(['maxCents'])); + const maxCents = optionValues.has('maxCents') ? optionValues.get('maxCents') : MAX_CENTS; + if (!Number.isSafeInteger(maxCents) || maxCents < 0 || maxCents > MAX_CENTS) { + invalidOptions(); + } + if (typeof value !== 'number' + || !Number.isSafeInteger(value) + || Object.is(value, -0) + || value < 0 + || value > maxCents) { + reject(REQUEST_VALIDATION_REASONS.INVALID_MONEY); + } + return value; +} + +function parseCurrency(value) { + let parsed; + try { + parsed = parseBoundedString(value, { + maxCodePoints: 3, + maxBytes: 3, + normalize: false, + trim: false, + }); + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + reject(REQUEST_VALIDATION_REASONS.INVALID_CURRENCY); + } + if (!/^[A-Za-z]{3}$/.test(parsed) || parsed.toLowerCase() !== 'usd') { + reject(REQUEST_VALIDATION_REASONS.INVALID_CURRENCY); + } + return 'usd'; +} + +function isLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseCalendarDate(value) { + if (typeof value !== 'string' + || value.length !== 10 + || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_DATE); + } + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + if (year < 1 || year > 9999 || month < 1 || month > 12) { + reject(REQUEST_VALIDATION_REASONS.INVALID_DATE); + } + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day < 1 || day > daysInMonth[month - 1]) { + reject(REQUEST_VALIDATION_REASONS.INVALID_DATE); + } + return value; +} + +function canonicalIpHostname(hostname) { + if (hostname.startsWith('[') && hostname.endsWith(']')) return hostname.slice(1, -1); + return hostname; +} + +function isLoopbackHostname(hostname) { + return hostname === 'localhost' + || hostname === '127.0.0.1' + || hostname === '[::1]' + || hostname === '::1'; +} + +function isValidDnsHostname(hostname) { + if (hostname.length > 253 || !hostname.includes('.') || hostname.endsWith('.')) return false; + const labels = hostname.split('.'); + if (labels.includes('localhost')) return false; + return labels.every((label) => ( + label.length >= 1 + && label.length <= 63 + && !label.startsWith('xn--') + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + )); +} + +function hasUnsafeUrlCharacter(value) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x20 || code === 0x7F || code === 0x5C) return true; + } + return false; +} + +function hasNoncanonicalPercentEncoding(value) { + const unreserved = /^[A-Za-z0-9._~-]$/; + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== '%') continue; + const hex = value.slice(index + 1, index + 3); + if (hex !== hex.toUpperCase()) return true; + if (unreserved.test(String.fromCharCode(Number.parseInt(hex, 16)))) return true; + index += 2; + } + return false; +} + +function parseHttpsUrl(value, options = {}) { + const optionValues = readOptionsObject(options, new Set(['allowLoopback'])); + const allowLoopback = optionValues.has('allowLoopback') + ? optionValues.get('allowLoopback') + : false; + if (typeof allowLoopback !== 'boolean') invalidOptions(); + let parsedValue; + try { + parsedValue = parseBoundedString(value, { + maxCodePoints: 2000, + maxBytes: 8192, + normalize: false, + trim: false, + }); + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + if (hasUnsafeUrlCharacter(parsedValue) + || /%(?![0-9A-Fa-f]{2})/.test(parsedValue) + || /%(?:0[0-9A-F]|1[0-9A-F]|7F)/i.test(parsedValue) + || hasNoncanonicalPercentEncoding(parsedValue)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + + let parsed; + try { + parsed = new URL(parsedValue); + } catch { + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + if (parsed.href !== parsedValue + || parsed.username !== '' + || parsed.password !== '') { + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + + const hostname = parsed.hostname; + const loopback = isLoopbackHostname(hostname); + if (loopback) { + if (!allowLoopback || !['http:', 'https:'].includes(parsed.protocol)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + return parsedValue; + } + if (parsed.protocol !== 'https:' + || isIP(canonicalIpHostname(hostname)) !== 0 + || !isValidDnsHostname(hostname)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_URL); + } + return parsedValue; +} + +function parseEmail(value) { + // Permit ordinary surrounding spaces for form input, but reject every + // non-ASCII/control character before normalization can create an alias. + if (typeof value !== 'string' + || value.length > 254 + || !/^[\x20-\x7E]*$/.test(value)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + let normalized; + try { + normalized = parseBoundedString(value, { + maxCodePoints: 254, + maxBytes: 254, + normalize: 'NFC', + trim: true, + }).toLowerCase(); + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + if (!/^[\x21-\x7E]+$/.test(normalized)) { + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + const at = normalized.indexOf('@'); + if (at <= 0 || at !== normalized.lastIndexOf('@')) { + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + if (local.length > 64 + || local.startsWith('.') + || local.endsWith('.') + || local.includes('..') + || !/^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/.test(local) + || domain.length > 253 + || !domain.includes('.')) { + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + const labels = domain.split('.'); + if (!labels.every((label) => ( + label.length >= 1 + && label.length <= 63 + && !label.startsWith('xn--') + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + ))) { + reject(REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + } + return normalized; +} + +module.exports = { + DEFAULT_REQUEST_LIMITS, + MAX_CENTS, + REQUEST_VALIDATION_MESSAGE, + REQUEST_VALIDATION_REASONS, + RequestValidationError, + parseBoundedArray, + parseBoundedString, + parseCalendarDate, + parseCurrency, + parseEmail, + parseHttpsUrl, + parseNonnegativeCents, + parseStrictObject, +}; diff --git a/functions/requestValidation.test.js b/functions/requestValidation.test.js new file mode 100644 index 00000000..9906faf7 --- /dev/null +++ b/functions/requestValidation.test.js @@ -0,0 +1,886 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { + DEFAULT_REQUEST_LIMITS, + MAX_CENTS, + REQUEST_VALIDATION_MESSAGE, + REQUEST_VALIDATION_REASONS, + RequestValidationError, + parseBoundedArray, + parseBoundedString, + parseCalendarDate, + parseCurrency, + parseEmail, + parseHttpsUrl, + parseNonnegativeCents, + parseStrictObject, +} = require('./requestValidation'); + +const HOSTILE_CANARY = 'private-canary@example.test/token?secret=do-not-copy'; + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected validation to fail'); +} + +function expectSafeError(callback, reason) { + const error = captureError(callback); + expect(error).toBeInstanceOf(RequestValidationError); + expect(error.message).toBe(REQUEST_VALIDATION_MESSAGE); + expect(error.reason).toBe(reason); + expect(Object.keys(error)).toEqual([]); + expect(JSON.stringify(error)).toBe('{}'); + expect(error.name).toBe('RequestValidationError'); + expect(error.stack).not.toContain(HOSTILE_CANARY); + return error; +} + +function strict(value, overrides = {}) { + return parseStrictObject(value, { + requiredKeys: ['required'], + optionalKeys: ['optional'], + ...overrides, + }); +} + +describe('strict request structure', () => { + test('returns a new recursively frozen, deterministic clone without changing input', () => { + const shared = { deep: ['one', { two: true }] }; + const input = { + optional: shared, + required: { shared, finiteFraction: 1.5, negative: -2 }, + }; + const before = JSON.stringify(input); + const rootDescriptors = Object.getOwnPropertyDescriptors(input); + const output = strict(input); + + expect(output).toEqual({ + optional: { deep: ['one', { two: true }] }, + required: { + finiteFraction: 1.5, + negative: -2, + shared: { deep: ['one', { two: true }] }, + }, + }); + expect(Reflect.ownKeys(output)).toEqual(['optional', 'required']); + expect(output).not.toBe(input); + expect(output.optional).not.toBe(shared); + expect(output.required.shared).not.toBe(shared); + expect(output.optional).not.toBe(output.required.shared); + expect(Object.isFrozen(output)).toBe(true); + expect(Object.isFrozen(output.required)).toBe(true); + expect(Object.isFrozen(output.optional.deep)).toBe(true); + expect(Object.isFrozen(output.optional.deep[1])).toBe(true); + expect(Object.getPrototypeOf(output)).toBeNull(); + expect(Object.getPrototypeOf(output.required)).toBeNull(); + expect(Object.getPrototypeOf(output.optional.deep[1])).toBeNull(); + expect(JSON.stringify(input)).toBe(before); + expect(Object.getOwnPropertyDescriptors(input)).toEqual(rootDescriptors); + expect(Object.isFrozen(input)).toBe(false); + expect(Object.isFrozen(shared)).toBe(false); + + output.required.negative = 4; + expect(() => output.optional.deep.push('three')).toThrow(TypeError); + expect(output.required.negative).toBe(-2); + expect(output.optional.deep).toHaveLength(2); + }); + + test.each([ + ['undefined', undefined], + ['null', null], + ['array', []], + ['date', new Date('2026-01-01T00:00:00Z')], + ['regular expression', /x/], + ['map', new Map()], + ['set', new Set()], + ['buffer', Buffer.from('x')], + ['boxed primitive', new String('x')], + ['null prototype', Object.create(null)], + ['class instance', new (class Example {})()], + ])('rejects a %s root', (_name, value) => { + expectSafeError(() => strict(value), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + }); + + test.each([ + ['missing required key', { optional: true }], + ['unknown key', { required: true, unknown: HOSTILE_CANARY }], + ])('rejects %s with a fixed field error', (_name, value) => { + expectSafeError(() => strict(value), REQUEST_VALIDATION_REASONS.INVALID_FIELDS); + }); + + test('rejects symbols, non-enumerable properties, and accessors without invoking them', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + for (const value of [ + (() => { + const candidate = { required: true }; + candidate[Symbol('hidden')] = HOSTILE_CANARY; + return candidate; + })(), + (() => { + const candidate = { required: true }; + Object.defineProperty(candidate, 'optional', { + value: HOSTILE_CANARY, + enumerable: false, + }); + return candidate; + })(), + (() => { + const candidate = { required: true }; + Object.defineProperty(candidate, 'optional', { get: getter, enumerable: true }); + return candidate; + })(), + (() => { + const candidate = { required: true }; + Object.defineProperty(candidate, 'unknown', { get: getter, enumerable: true }); + return candidate; + })(), + ]) { + expectSafeError(() => strict(value), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + } + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects custom and polluted prototypes', () => { + const inherited = Object.create({ inherited: HOSTILE_CANARY }); + inherited.required = true; + expectSafeError(() => strict(inherited), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + + Object.defineProperty(Object.prototype, 'temporaryPollutionProbe', { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + try { + expectSafeError( + () => strict({ required: true }), + REQUEST_VALIDATION_REASONS.INVALID_OBJECT, + ); + } finally { + delete Object.prototype.temporaryPollutionProbe; + } + }); + + test.each(['__proto__', 'prototype', 'constructor'])( + 'rejects dangerous own key %s at root and nested boundaries', + (key) => { + const root = JSON.parse(`{"required":true,"${key}":"blocked"}`); + expectSafeError(() => strict(root), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + + const nested = { required: JSON.parse(`{"${key}":"blocked"}`) }; + expectSafeError(() => strict(nested), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + }, + ); + + test('rejects a Proxy before any reflection trap can run', () => { + const traps = { + getPrototypeOf: jest.fn(() => Object.prototype), + ownKeys: jest.fn(() => ['required']), + getOwnPropertyDescriptor: jest.fn(() => ({ + value: true, enumerable: true, configurable: true, writable: true, + })), + get: jest.fn(() => true), + }; + const proxy = new Proxy({}, traps); + + expectSafeError(() => strict(proxy), REQUEST_VALIDATION_REASONS.INVALID_OBJECT); + Object.values(traps).forEach((trap) => expect(trap).not.toHaveBeenCalled()); + }); + + test('rejects cycles but accepts and independently clones shared references', () => { + const self = { required: true }; + self.optional = self; + expectSafeError(() => strict(self), REQUEST_VALIDATION_REASONS.INVALID_VALUE); + + const left = {}; + const right = {}; + left.right = right; + right.left = left; + expectSafeError( + () => strict({ required: left }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + + const shared = { safe: true }; + const output = strict({ required: shared, optional: shared }); + expect(output.required).toEqual(shared); + expect(output.optional).toEqual(shared); + expect(output.required).not.toBe(output.optional); + }); + + test.each([ + ['undefined', undefined], + ['function', () => {}], + ['symbol', Symbol('value')], + ['bigint', 1n], + ['NaN', NaN], + ['positive infinity', Infinity], + ['negative infinity', -Infinity], + ['negative zero', -0], + ])('rejects nested non-JSON value %s', (_name, value) => { + expectSafeError( + () => strict({ required: value }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + }); + + test('uses one depth and entry budget across the complete tree', () => { + const atDepth = { required: { a: { b: true } } }; + expect(strict(atDepth, { limits: { maxDepth: 2 } })).toEqual(atDepth); + expectSafeError( + () => strict({ required: { a: { b: { c: true } } } }, { + limits: { maxDepth: 2 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + + const atEntries = { required: { a: true }, optional: false }; + expect(strict(atEntries, { limits: { maxEntries: 3 } })).toEqual(atEntries); + expectSafeError( + () => strict({ required: { a: true, b: false }, optional: false }, { + limits: { maxEntries: 3 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + }); + + test('enforces exact array, string, and serialized-byte boundaries', () => { + expect(strict({ required: [1, 2] }, { + limits: { maxArrayLength: 2 }, + })).toEqual({ required: [1, 2] }); + expectSafeError( + () => strict({ required: [1, 2, 3] }, { + limits: { maxArrayLength: 2 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + + expect(strict({ required: '😀' }, { + limits: { maxStringCodePoints: 1, maxStringBytes: 4 }, + })).toEqual({ required: '😀' }); + expectSafeError( + () => strict({ required: '😀😀' }, { + limits: { maxStringCodePoints: 1 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + expectSafeError( + () => strict({ required: '😀' }, { + limits: { maxStringBytes: 3 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + + const payload = { required: 'abc' }; + const exactBytes = Buffer.byteLength(JSON.stringify(payload)); + expect(strict(payload, { limits: { maxSerializedBytes: exactBytes } })).toEqual(payload); + expectSafeError( + () => strict(payload, { limits: { maxSerializedBytes: exactBytes - 1 } }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + + const escapedPayload = { required: '"\\\b\t\n\f\r\0😀' }; + const escapedBytes = Buffer.byteLength(JSON.stringify(escapedPayload)); + expect(strict(escapedPayload, { + limits: { maxSerializedBytes: escapedBytes }, + })).toEqual(escapedPayload); + expectSafeError( + () => strict(escapedPayload, { + limits: { maxSerializedBytes: escapedBytes - 1 }, + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + + const oversizedKey = 'k'.repeat(DEFAULT_REQUEST_LIMITS.maxKeyCodePoints + 1); + expectSafeError( + () => parseStrictObject({ [oversizedKey]: true }, { + optionalKeys: [], + }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + }); + + test('never invokes inherited toJSON hooks while measuring serialized bytes', () => { + const objectHook = jest.fn(() => { + throw new Error(HOSTILE_CANARY); + }); + const arrayHook = jest.fn(() => { + throw new Error(HOSTILE_CANARY); + }); + let output; + Object.defineProperty(Object.prototype, 'toJSON', { + value: objectHook, + enumerable: false, + configurable: true, + }); + Object.defineProperty(Array.prototype, 'toJSON', { + value: arrayHook, + enumerable: false, + configurable: true, + }); + try { + output = strict({ required: [{ safe: true }] }); + } finally { + delete Array.prototype.toJSON; + delete Object.prototype.toJSON; + } + expect(output).toEqual({ required: [{ safe: true }] }); + expect(objectHook).not.toHaveBeenCalled(); + expect(arrayHook).not.toHaveBeenCalled(); + }); + + test('rejects sparse, accessor, symbol, extra-property, subclass, and Proxy arrays', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + const sparse = new Array(2); + sparse[0] = 'one'; + const accessor = ['one']; + Object.defineProperty(accessor, '0', { get: getter, enumerable: true }); + const symbol = ['one']; + symbol[Symbol('hidden')] = true; + const extra = ['one']; + extra.extra = true; + class ArraySubclass extends Array {} + const subclass = new ArraySubclass('one'); + const traps = { get: jest.fn(), ownKeys: jest.fn() }; + const proxy = new Proxy(['one'], traps); + + for (const value of [sparse, accessor, symbol, extra, subclass]) { + expectSafeError( + () => strict({ required: value }), + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + ); + } + expectSafeError( + () => strict({ required: proxy }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + expect(getter).not.toHaveBeenCalled(); + Object.values(traps).forEach((trap) => expect(trap).not.toHaveBeenCalled()); + }); + + test('rejects an inherited enumerable array field without reading it', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + Object.defineProperty(Array.prototype, 'pollutedCanary', { + get: getter, + enumerable: true, + configurable: true, + }); + try { + expectSafeError( + () => strict({ required: ['safe'] }), + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + ); + expectSafeError( + () => parseBoundedArray(['safe'], { + maxItems: 1, + itemParser: (item) => item, + }), + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + ); + } finally { + delete Array.prototype.pollutedCanary; + } + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects unsafe validator configuration with one fixed programmer error', () => { + const badOptions = [ + { requiredKeys: ['same'], optionalKeys: ['same'] }, + { requiredKeys: ['duplicate', 'duplicate'] }, + { requiredKeys: ['__proto__'] }, + { requiredKeys: ['not-valid-field'] }, + { requiredKeys: null }, + { requiredKeys: false }, + { requiredKeys: 0 }, + { requiredKeys: '' }, + { optionalKeys: null }, + { optionalKeys: false }, + { optionalKeys: 0 }, + { optionalKeys: '' }, + { limits: { maxDepth: DEFAULT_REQUEST_LIMITS.maxDepth + 1 } }, + { limits: { unknownLimit: 1 } }, + ]; + for (const options of badOptions) { + expect(() => parseStrictObject({}, options)).toThrow( + new TypeError('Invalid validation options'), + ); + } + }); +}); + +describe('bounded array and string primitives', () => { + test('maps a dense array into a new deeply frozen result', () => { + const input = [{ value: 'one' }, { value: 'two' }]; + const parsed = parseBoundedArray(input, { + maxItems: 2, + itemParser: (item) => parseStrictObject(item, { requiredKeys: ['value'] }), + }); + + expect(parsed).toEqual(input); + expect(parsed).not.toBe(input); + expect(parsed[0]).not.toBe(input[0]); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed[0])).toBe(true); + expect(Object.isFrozen(input)).toBe(false); + expect(Object.isFrozen(input[0])).toBe(false); + }); + + test('gives the item parser a bounded frozen clone and cannot mutate caller input', () => { + const input = [{ nested: { value: 'before' } }]; + const before = JSON.stringify(input); + let parserInput; + const parsed = parseBoundedArray(input, { + maxItems: 1, + itemParser: (item) => { + parserInput = item; + expect(Reflect.set(item.nested, 'value', 'after')).toBe(false); + return item; + }, + }); + + expect(JSON.stringify(input)).toBe(before); + expect(parserInput).not.toBe(input[0]); + expect(Object.isFrozen(parserInput)).toBe(true); + expect(Object.isFrozen(parserInput.nested)).toBe(true); + expect(parsed).toEqual(input); + expect(parsed[0]).not.toBe(parserInput); + }); + + test('bounds raw items before a projecting item parser can hide them', () => { + const itemParser = jest.fn(() => 'projected'); + const deep = { level: true }; + let cursor = deep; + for (let index = 0; index <= DEFAULT_REQUEST_LIMITS.maxDepth; index += 1) { + cursor.next = {}; + cursor = cursor.next; + } + expectSafeError( + () => parseBoundedArray([deep], { maxItems: 1, itemParser }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + expect(itemParser).not.toHaveBeenCalled(); + + const oversized = Array.from( + { length: 33 }, + () => 'a'.repeat(DEFAULT_REQUEST_LIMITS.maxStringCodePoints), + ); + expect(Buffer.byteLength(JSON.stringify(oversized))).toBeGreaterThan( + DEFAULT_REQUEST_LIMITS.maxSerializedBytes, + ); + expectSafeError( + () => parseBoundedArray(oversized, { maxItems: 33, itemParser }), + REQUEST_VALIDATION_REASONS.PAYLOAD_LIMIT, + ); + expect(itemParser).not.toHaveBeenCalled(); + }); + + test('validates the complete array before calling its trusted item parser', () => { + const itemParser = jest.fn((value) => value); + const invalid = ['one']; + Object.defineProperty(invalid, '0', { + get: jest.fn(() => HOSTILE_CANARY), + enumerable: true, + }); + expectSafeError( + () => parseBoundedArray(invalid, { maxItems: 1, itemParser }), + REQUEST_VALIDATION_REASONS.INVALID_ARRAY, + ); + expect(itemParser).not.toHaveBeenCalled(); + }); + + test('converts an unsafe item-parser failure to a fixed validation error', () => { + const unsafeParser = (value) => { + throw new Error(value); + }; + expectSafeError( + () => parseBoundedArray([HOSTILE_CANARY], { + maxItems: 1, + itemParser: unsafeParser, + }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + }); + + test('does not inspect a Proxy thrown by an item parser', () => { + const getPrototypeOf = jest.fn(() => { + throw new Error(HOSTILE_CANARY); + }); + const thrown = new Proxy({}, { getPrototypeOf }); + expectSafeError( + () => parseBoundedArray(['safe'], { + maxItems: 1, + itemParser: () => { + throw thrown; + }, + }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + expect(getPrototypeOf).not.toHaveBeenCalled(); + }); + + test('does not trust a caller-constructed validation error', () => { + const fabricated = new RequestValidationError( + REQUEST_VALIDATION_REASONS.INVALID_EMAIL, + ); + expect(Reflect.set(fabricated, 'message', HOSTILE_CANARY)).toBe(false); + expect(Reflect.set(fabricated, 'stack', HOSTILE_CANARY)).toBe(false); + expectSafeError( + () => parseBoundedArray(['safe'], { + maxItems: 1, + itemParser: () => { + throw fabricated; + }, + }), + REQUEST_VALIDATION_REASONS.INVALID_VALUE, + ); + }); + + test('recaptures a genuine parser error without a value-derived function name', () => { + const namedParser = { + [HOSTILE_CANARY]: () => parseEmail('invalid'), + }[HOSTILE_CANARY]; + expectSafeError( + () => parseBoundedArray(['safe'], { + maxItems: 1, + itemParser: namedParser, + }), + REQUEST_VALIDATION_REASONS.INVALID_EMAIL, + ); + }); + + test('bounded arrays never invoke inherited toJSON hooks', () => { + const objectHook = jest.fn(() => { + throw new Error(HOSTILE_CANARY); + }); + const arrayHook = jest.fn(() => { + throw new Error(HOSTILE_CANARY); + }); + let output; + Object.defineProperty(Object.prototype, 'toJSON', { + value: objectHook, + enumerable: false, + configurable: true, + }); + Object.defineProperty(Array.prototype, 'toJSON', { + value: arrayHook, + enumerable: false, + configurable: true, + }); + try { + output = parseBoundedArray([{ safe: true }], { + maxItems: 1, + itemParser: (item) => item, + }); + } finally { + delete Array.prototype.toJSON; + delete Object.prototype.toJSON; + } + expect(output).toEqual([{ safe: true }]); + expect(objectHook).not.toHaveBeenCalled(); + expect(arrayHook).not.toHaveBeenCalled(); + }); + + test('normalizes NFC, counts code points, enforces bytes, and preserves opaque strings', () => { + expect(parseBoundedString('e\u0301', { + maxCodePoints: 2, + maxBytes: 3, + })).toBe('é'); + expect(parseBoundedString('😀', { + maxCodePoints: 1, + maxBytes: 4, + })).toBe('😀'); + expect(parseBoundedString(' value ', { + maxCodePoints: 9, + maxBytes: 9, + trim: true, + })).toBe('value'); + expect(parseBoundedString('e\u0301', { + maxCodePoints: 2, + maxBytes: 3, + normalize: false, + })).toBe('e\u0301'); + + expectSafeError( + () => parseBoundedString('😀😀', { maxCodePoints: 1, maxBytes: 8 }), + REQUEST_VALIDATION_REASONS.INVALID_STRING, + ); + expectSafeError( + () => parseBoundedString('😀', { maxCodePoints: 1, maxBytes: 3 }), + REQUEST_VALIDATION_REASONS.INVALID_STRING, + ); + expectSafeError( + () => parseBoundedString('\uD800', { maxCodePoints: 1, maxBytes: 3 }), + REQUEST_VALIDATION_REASONS.INVALID_STRING, + ); + expectSafeError( + () => parseBoundedString('\uDC00', { maxCodePoints: 1, maxBytes: 3 }), + REQUEST_VALIDATION_REASONS.INVALID_STRING, + ); + }); + + test('treats hostile markup and spreadsheet text as inert bounded data', () => { + const fetchSpy = jest.fn(() => { + throw new Error('network must not be called'); + }); + const previousFetch = global.fetch; + global.fetch = fetchSpy; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger must not be called'); + }); + try { + const input = '=HYPERLINK("x")'; + expect(parseBoundedString(input, { + maxCodePoints: 100, + maxBytes: 100, + })).toBe(input); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(consoleSpy).not.toHaveBeenCalled(); + } finally { + global.fetch = previousFetch; + consoleSpy.mockRestore(); + } + }); +}); + +describe('money, currency, date, URL, and email primitives', () => { + test.each([0, 1, MAX_CENTS])('accepts nonnegative integer cents %s', (value) => { + expect(parseNonnegativeCents(value)).toBe(value); + }); + + test.each([ + -1, + -0, + MAX_CENTS + 1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + NaN, + Infinity, + new Number(1), + '1', + '+1', + '-1', + '1e2', + true, + null, + 1n, + [], + {}, + ])('rejects unsafe cents value %p', (value) => { + expectSafeError( + () => parseNonnegativeCents(value), + REQUEST_VALIDATION_REASONS.INVALID_MONEY, + ); + }); + + test('applies an explicit lower endpoint ceiling and treats numeric exponent syntax as its value', () => { + expect(parseNonnegativeCents(100, { maxCents: 100 })).toBe(100); + // By the time JavaScript receives it, numeric 1e2 is exactly the number 100. + expect(parseNonnegativeCents(1e2, { maxCents: 100 })).toBe(100); + expectSafeError( + () => parseNonnegativeCents(101, { maxCents: 100 }), + REQUEST_VALIDATION_REASONS.INVALID_MONEY, + ); + expect(() => parseNonnegativeCents(1, { maxCents: MAX_CENTS + 1 })) + .toThrow(new TypeError('Invalid validation options')); + }); + + test('normalizes only the fixed launch currency', () => { + expect(parseCurrency('usd')).toBe('usd'); + expect(parseCurrency('USD')).toBe('usd'); + for (const value of [' usd ', 'us', 'usdd', 'eur', 'u5d', 'uѕd', new String('usd')]) { + expectSafeError( + () => parseCurrency(value), + REQUEST_VALIDATION_REASONS.INVALID_CURRENCY, + ); + } + }); + + test.each(['0001-01-01', '2024-02-29', '9999-12-31'])( + 'accepts canonical calendar date %s', + (value) => expect(parseCalendarDate(value)).toBe(value), + ); + + test.each([ + '0000-01-01', + '2023-02-29', + '2024-04-31', + '2024-00-01', + '2024-13-01', + '2024-01-00', + '2024-01-32', + '2024-1-01', + ' 2024-01-01', + '2024-01-01T00:00:00Z', + '2024-01-01', + 20240101, + new Date('2024-01-01T00:00:00Z'), + ])('rejects invalid calendar date %p', (value) => { + expectSafeError( + () => parseCalendarDate(value), + REQUEST_VALIDATION_REASONS.INVALID_DATE, + ); + }); + + test.each([ + 'https://example.test/', + 'https://example.test/A/~/%2F/%20', + 'https://example.test/path?view=public#section', + 'https://sub.example.test:8443/path', + ])('accepts canonical HTTPS DNS URL %s', (value) => { + expect(parseHttpsUrl(value)).toBe(value); + }); + + test.each([ + 'http://localhost:3000/', + 'https://localhost:3000/', + 'http://127.0.0.1:5001/path', + 'http://[::1]:5001/path', + ])('accepts canonical loopback URL only when explicitly enabled: %s', (value) => { + expectSafeError(() => parseHttpsUrl(value), REQUEST_VALIDATION_REASONS.INVALID_URL); + expect(parseHttpsUrl(value, { allowLoopback: true })).toBe(value); + }); + + test.each([ + 'http://example.test/', + 'ftp://example.test/', + 'file:///tmp/example', + 'data:text/plain,hello', + 'javascript:alert(1)', + '//example.test/path', + 'https://user:pass@example.test/', + 'https://example.test', + 'HTTPS://example.test/', + 'https://EXAMPLE.test/', + 'https://example.test:443/', + 'https://example.test/a/../b', + 'https://example.test\\evil.test/', + 'https://example.test/%0aheader', + 'https://example.test/%7f', + 'https://example.test/%zz', + 'https://example.test/%41', + 'https://example.test/%7E', + 'https://example.test/%7e', + 'https://example.test/%2f', + 'https://127.1/', + 'https://2130706433/', + 'https://0x7f000001/', + 'https://127.0.0.2/', + 'https://8.8.8.8/', + 'https://[::ffff:7f00:1]/', + 'https://0.0.0.0/', + 'https://localhost.example.test/', + 'https://xn--bcher-kva.example/', + ])('rejects noncanonical or unsafe URL %s', (value) => { + expectSafeError( + () => parseHttpsUrl(value, { allowLoopback: true }), + REQUEST_VALIDATION_REASONS.INVALID_URL, + ); + }); + + test('bounds URL length and rejects Proxy options before traps', () => { + expectSafeError( + () => parseHttpsUrl(`https://example.test/${'a'.repeat(2030)}`), + REQUEST_VALIDATION_REASONS.INVALID_URL, + ); + const get = jest.fn(() => true); + const options = new Proxy({}, { get }); + expect(() => parseHttpsUrl('https://example.test/', options)) + .toThrow(new TypeError('Invalid validation options')); + expect(get).not.toHaveBeenCalled(); + }); + + test('normalizes a conservative ASCII email without claiming identity or delivery', () => { + expect(parseEmail(' Person+Race@Example.TEST ')).toBe('person+race@example.test'); + const local = 'a'.repeat(64); + const domain = `${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`; + const boundary = `${local}@${domain}`; + expect(boundary).toHaveLength(254); + expect(parseEmail(boundary)).toBe(boundary); + }); + + test.each([ + '', + 'missing-at.example.test', + '@example.test', + 'two@@example.test', + '.leading@example.test', + 'trailing.@example.test', + 'two..dots@example.test', + 'space here@example.test', + '\tperson@example.test', + 'person@example.test\n', + 'line\r\nheader@example.test', + 'nul\0@example.test', + 'person@example', + 'person@example..test', + 'person@_invalid.example', + 'person@-invalid.example', + 'person@invalid-.example', + `person@${'a'.repeat(64)}.test`, + `${'a'.repeat(65)}@example.test`, + `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(62)}`, + '"quoted"@example.test', + 'person(comment)@example.test', + 'person@[127.0.0.1]', + 'person@bücher.example', + 'K@example.test', + 'person@xn--bcher-kva.example', + {}, + ])('rejects invalid or unsupported email %p', (value) => { + expectSafeError(() => parseEmail(value), REQUEST_VALIDATION_REASONS.INVALID_EMAIL); + }); +}); + +describe('fixed errors and pure dependency boundary', () => { + test('never includes a hostile supplied value in any public validation error', () => { + const cases = [ + () => strict({ required: true, unknown: HOSTILE_CANARY }), + () => parseBoundedString(HOSTILE_CANARY, { maxCodePoints: 1, maxBytes: 1 }), + () => parseNonnegativeCents(HOSTILE_CANARY), + () => parseCurrency(HOSTILE_CANARY), + () => parseCalendarDate(HOSTILE_CANARY), + () => parseHttpsUrl(HOSTILE_CANARY), + () => parseEmail(HOSTILE_CANARY), + ]; + for (const callback of cases) { + const error = captureError(callback); + expect(error).toBeInstanceOf(RequestValidationError); + expect(`${error.name}${error.message}${error.stack}${JSON.stringify(error)}`) + .not.toContain(HOSTILE_CANARY); + } + }); + + test('uses only pure Node inspection helpers and performs no external/log/random/time call', () => { + const source = fs.readFileSync(path.join(__dirname, 'requestValidation.js'), 'utf8'); + const requires = [...source.matchAll(/require\(['"]([^'"]+)['"]\)/g)] + .map((match) => match[1]); + expect(requires).toEqual(['node:net', 'node:util']); + expect(source).not.toMatch(/firebase|stripe|fetch\s*\(|console\.|Date\.now|Math\.random|setTimeout/); + + const previousFetch = global.fetch; + const fetchSpy = jest.fn(); + global.fetch = fetchSpy; + const consoleSpies = ['log', 'info', 'warn', 'error'].map((method) => ( + jest.spyOn(console, method).mockImplementation(() => {}) + )); + const dateSpy = jest.spyOn(Date, 'now'); + const randomSpy = jest.spyOn(Math, 'random'); + try { + expect(strict({ required: 'safe' })).toEqual({ required: 'safe' }); + expect(parseEmail('safe@example.test')).toBe('safe@example.test'); + expect(fetchSpy).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(dateSpy).not.toHaveBeenCalled(); + expect(randomSpy).not.toHaveBeenCalled(); + } finally { + global.fetch = previousFetch; + consoleSpies.forEach((spy) => spy.mockRestore()); + dateSpy.mockRestore(); + randomSpy.mockRestore(); + } + }); +}); diff --git a/functions/safeLogging.js b/functions/safeLogging.js new file mode 100644 index 00000000..46d27bbb --- /dev/null +++ b/functions/safeLogging.js @@ -0,0 +1,118 @@ +const { + REQUEST_VALIDATION_REASONS, + RequestValidationError, + parseStrictObject, +} = require('./requestValidation'); + +// Every accepted value is a reviewed low-cardinality code. This primitive does +// not accept correlation IDs, provider IDs, URLs, contact data, or raw errors. +const SAFE_LOG_VALUE_LISTS = Object.freeze(Object.assign(Object.create(null), { + event: Object.freeze([ + 'request_validation', + 'command_admission', + 'command_execution', + 'provider_transition', + 'reconciliation', + 'security_control', + ]), + operation: Object.freeze([ + 'race_registration', + 'merchandise_checkout', + 'registration_lookup', + 'order_lookup', + 'registration_refund', + 'order_refund', + 'registration_cancel', + 'order_cancel', + 'registration_substitute', + 'late_registration', + 'complimentary_registration', + 'role_change', + 'tracking_update', + 'export', + 'webhook', + 'reconciliation', + 'profile_recovery', + 'authentication', + ]), + outcome: Object.freeze([ + 'accepted', + 'rejected', + 'succeeded', + 'failed', + 'retryable', + 'quarantined', + 'no_change', + ]), + code: Object.freeze([ + 'none', + 'invalid_request', + 'invalid_object', + 'invalid_fields', + 'invalid_value', + 'payload_limit', + 'invalid_string', + 'invalid_array', + 'invalid_money', + 'invalid_currency', + 'invalid_date', + 'invalid_url', + 'invalid_email', + 'unauthenticated', + 'unauthorized', + 'configuration_unavailable', + 'commerce_unavailable', + 'rate_limited', + 'provider_unavailable', + 'state_conflict', + 'internal_error', + ]), + environment: Object.freeze(['local', 'test', 'staging', 'production']), +})); + +const SAFE_LOG_FIELDS = Object.freeze(['code', 'environment', 'event', 'operation', 'outcome']); +const SAFE_LOG_VALUES = Object.assign( + Object.create(null), + Object.fromEntries( + Object.entries(SAFE_LOG_VALUE_LISTS).map(([field, values]) => [field, new Set(values)]), + ), +); + +function rejectSafeLog() { + throw new RequestValidationError(REQUEST_VALIDATION_REASONS.SAFE_LOG_INVALID); +} + +/** + * Build a flat, deterministic, low-cardinality diagnostic projection. + * This function deliberately does not call a logger. + */ +function buildSafeLogProjection(value) { + let parsed; + try { + parsed = parseStrictObject(value, { requiredKeys: SAFE_LOG_FIELDS }); + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + rejectSafeLog(); + } + + for (const field of SAFE_LOG_FIELDS) { + if (!SAFE_LOG_VALUES[field].has(parsed[field])) rejectSafeLog(); + } + + const projection = Object.create(null); + for (const field of SAFE_LOG_FIELDS) { + Object.defineProperty(projection, field, { + value: parsed[field], + enumerable: true, + writable: true, + configurable: true, + }); + } + return Object.freeze(projection); +} + +module.exports = { + SAFE_LOG_FIELDS, + SAFE_LOG_VALUE_LISTS, + buildSafeLogProjection, +}; diff --git a/functions/safeLogging.test.js b/functions/safeLogging.test.js new file mode 100644 index 00000000..4688b45f --- /dev/null +++ b/functions/safeLogging.test.js @@ -0,0 +1,205 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { + REQUEST_VALIDATION_MESSAGE, + REQUEST_VALIDATION_REASONS, + RequestValidationError, +} = require('./requestValidation'); +const { + SAFE_LOG_FIELDS, + SAFE_LOG_VALUE_LISTS, + buildSafeLogProjection, +} = require('./safeLogging'); + +const VALID_LOG = Object.freeze({ + event: 'request_validation', + operation: 'race_registration', + outcome: 'rejected', + code: 'invalid_fields', + environment: 'test', +}); +const HOSTILE_CANARY = 'private-canary@example.test/callback?token=never-log'; + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected safe-log projection to fail'); +} + +function expectSafeLogError(callback) { + const error = captureError(callback); + expect(error).toBeInstanceOf(RequestValidationError); + expect(error.message).toBe(REQUEST_VALIDATION_MESSAGE); + expect(error.reason).toBe(REQUEST_VALIDATION_REASONS.SAFE_LOG_INVALID); + expect(Object.keys(error)).toEqual([]); + expect(JSON.stringify(error)).toBe('{}'); + expect(`${error.name}${error.message}${error.stack}${JSON.stringify(error)}`) + .not.toContain(HOSTILE_CANARY); +} + +describe('safe low-cardinality log projection', () => { + test('returns only fixed fields and values in a new deterministic frozen object', () => { + const input = { ...VALID_LOG }; + const output = buildSafeLogProjection(input); + + expect(output).toEqual({ + code: 'invalid_fields', + environment: 'test', + event: 'request_validation', + operation: 'race_registration', + outcome: 'rejected', + }); + expect(Reflect.ownKeys(output)).toEqual(SAFE_LOG_FIELDS); + expect(output).not.toBe(input); + expect(Object.isFrozen(output)).toBe(true); + expect(Object.getPrototypeOf(output)).toBeNull(); + expect(Object.isFrozen(input)).toBe(false); + output.code = HOSTILE_CANARY; + expect(output.code).toBe('invalid_fields'); + + const second = buildSafeLogProjection({ ...VALID_LOG }); + expect(second).toEqual(output); + expect(second).not.toBe(output); + }); + + test('the exported documentation allowlist is deeply frozen and contains only fixed tokens', () => { + expect(Object.isFrozen(SAFE_LOG_FIELDS)).toBe(true); + expect(Object.isFrozen(SAFE_LOG_VALUE_LISTS)).toBe(true); + expect(Object.getPrototypeOf(SAFE_LOG_VALUE_LISTS)).toBeNull(); + for (const [field, values] of Object.entries(SAFE_LOG_VALUE_LISTS)) { + expect(SAFE_LOG_FIELDS).toContain(field); + expect(Object.isFrozen(values)).toBe(true); + expect(new Set(values).size).toBe(values.length); + values.forEach((value) => expect(value).toMatch(/^[a-z][a-z0-9_]{0,47}$/)); + expect(Reflect.set(values, values.length, HOSTILE_CANARY)).toBe(false); + } + }); + + test.each([ + ['missing field', (() => { + const value = { ...VALID_LOG }; + delete value.code; + return value; + })()], + ['unknown key', { ...VALID_LOG, arbitrary: 'accepted' }], + ['unknown fixed field value', { ...VALID_LOG, outcome: 'member@example.test' }], + ['nested value', { ...VALID_LOG, code: { raw: HOSTILE_CANARY } }], + ['raw Error', { ...VALID_LOG, code: new Error(HOSTILE_CANARY) }], + ])('rejects %s rather than emitting it', (_name, input) => { + expectSafeLogError(() => buildSafeLogProjection(input)); + }); + + test.each([ + ['requestBody', { nested: HOSTILE_CANARY }], + ['authorization', HOSTILE_CANARY], + ['token', HOSTILE_CANARY], + ['sessionCookie', HOSTILE_CANARY], + ['checkoutUrl', `https://${HOSTILE_CANARY}`], + ['callbackUrl', `https://${HOSTILE_CANARY}`], + ['email', 'member@example.test'], + ['phone', '+1-555-0100'], + ['address', 'private street'], + ['dateOfBirth', '2000-01-01'], + ['emergencyContact', HOSTILE_CANARY], + ['stripePaymentIntent', 'pi_private_canary'], + ['oauthAccessToken', 'oauth-private-canary'], + ['rawMessage', HOSTILE_CANARY], + ['stack', HOSTILE_CANARY], + ['businessId', HOSTILE_CANARY], + ])('rejects prohibited category %s even beside an otherwise valid record', (key, value) => { + const input = { ...VALID_LOG, [key]: value }; + expectSafeLogError(() => buildSafeLogProjection(input)); + expect(JSON.stringify(input)).toContain( + typeof value === 'string' ? value : HOSTILE_CANARY, + ); + }); + + test('rejects symbols, non-enumerable fields, accessors, and dangerous keys without reading them', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + const symbol = { ...VALID_LOG }; + symbol[Symbol('private')] = HOSTILE_CANARY; + const nonEnumerable = { ...VALID_LOG }; + Object.defineProperty(nonEnumerable, 'privateField', { + value: HOSTILE_CANARY, + enumerable: false, + }); + const accessor = { ...VALID_LOG }; + Object.defineProperty(accessor, 'outcome', { get: getter, enumerable: true }); + const dangerous = JSON.parse(`{${Object.entries(VALID_LOG) + .map(([key, value]) => `"${key}":"${value}"`).join(',')},"__proto__":"${HOSTILE_CANARY}"}`); + + for (const input of [symbol, nonEnumerable, accessor, dangerous]) { + expectSafeLogError(() => buildSafeLogProjection(input)); + } + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects a Proxy before any reflection trap can execute', () => { + const traps = { + getPrototypeOf: jest.fn(() => Object.prototype), + ownKeys: jest.fn(() => Reflect.ownKeys(VALID_LOG)), + getOwnPropertyDescriptor: jest.fn(), + get: jest.fn(), + }; + const proxy = new Proxy({ ...VALID_LOG }, traps); + + expectSafeLogError(() => buildSafeLogProjection(proxy)); + Object.values(traps).forEach((trap) => expect(trap).not.toHaveBeenCalled()); + }); + + test('serializes without invoking an inherited toJSON hook', () => { + const hook = jest.fn(() => HOSTILE_CANARY); + let serialized; + Object.defineProperty(Object.prototype, 'toJSON', { + value: hook, + enumerable: false, + configurable: true, + }); + try { + serialized = JSON.stringify(buildSafeLogProjection({ ...VALID_LOG })); + } finally { + delete Object.prototype.toJSON; + } + expect(serialized).toBe(JSON.stringify({ + code: 'invalid_fields', + environment: 'test', + event: 'request_validation', + operation: 'race_registration', + outcome: 'rejected', + })); + expect(hook).not.toHaveBeenCalled(); + }); + + test('never invokes a logger, network, clock, randomness, or provider dependency', () => { + const source = fs.readFileSync(path.join(__dirname, 'safeLogging.js'), 'utf8'); + const requires = [...source.matchAll(/require\(['"]([^'"]+)['"]\)/g)] + .map((match) => match[1]); + expect(requires).toEqual(['./requestValidation']); + expect(source).not.toMatch(/firebase|stripe|fetch\s*\(|console\.|Date\.now|Math\.random|setTimeout/); + + const previousFetch = global.fetch; + const fetchSpy = jest.fn(); + global.fetch = fetchSpy; + const consoleSpies = ['log', 'info', 'warn', 'error'].map((method) => ( + jest.spyOn(console, method).mockImplementation(() => {}) + )); + const dateSpy = jest.spyOn(Date, 'now'); + const randomSpy = jest.spyOn(Math, 'random'); + try { + expect(buildSafeLogProjection({ ...VALID_LOG })).toEqual(VALID_LOG); + expect(fetchSpy).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(dateSpy).not.toHaveBeenCalled(); + expect(randomSpy).not.toHaveBeenCalled(); + } finally { + global.fetch = previousFetch; + consoleSpies.forEach((spy) => spy.mockRestore()); + dateSpy.mockRestore(); + randomSpy.mockRestore(); + } + }); +});