@blamejs/core 0.7.4 → 0.7.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,36 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.19** (2026-05-05) — auth-primitives batch (5 fixes): session idle/absolute timeout, JWT keyResolver for kid rotation, `b.middleware.bearerAuth`, `b.auth.jwt.verifyExternal` (classical-alg JWT verifier with JWKS support), and Argon2id parameter audit visibility. **Session idle + absolute timeouts** (`b.session.verify`) now enforce OWASP ASVS 5.0 §3.3 / NIST SP 800-63B-4 — defaults: 30 min idle, 12 hours absolute. Operators opt out per-call by passing `idleTimeoutMs: 0` / `absoluteTimeoutMs: 0`. Both surface `auth.session.expired_idle` / `auth.session.expired_absolute` audit events on enforcement. **JWT `keyResolver` opt** (`b.auth.jwt.verify`) — operator passes `keyResolver(decodedHeader)` to look up the public key per-token (typically by `kid`). Mutually exclusive with `opts.publicKey`. Async-friendly. Closes the kid-rotation gap where signers carried `kid` but verifiers only accepted a single static key. **`b.middleware.bearerAuth`** — new middleware that extracts `Authorization: Bearer <token>`, runs an operator-supplied `verify(token)` function, and attaches `req.user`. Distinct from cookie-session `attachUser`. Missing-Authorization passes through (so cookie path can take over); invalid/null/throw rejects 401 + `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3. **`b.auth.jwt.verifyExternal`** — new generic classical-alg JWT verifier (RS256 / RS384 / RS512 / PS256 / PS384 / PS512 / ES256 / ES384 / ES512 / EdDSA) for integration with external IdPs (Auth0 / Okta / Keycloak / Cognito / Azure AD / Google / Apple). `algorithms` is REQUIRED with no default — defends the alg-confusion class (CVE-2024-54150 / CVE-2025-30144 / CVE-2026-22817 Hono). HMAC algs and `none` are explicitly refused (HMAC + JWKS public-key trust source IS the alg-confusion vector). Three key-source options: `jwks` (pre-fetched array), `jwksUri` (auto-fetched + TTL-cached via `b.httpClient` SSRF gate), `keyResolver` (custom). Standard claim checks (`exp` / `nbf` / `iat` / `aud` / `iss` / `sub`) with operator-tunable `clockSkewMs`. **`b.auth.password.params()`** — new accessor returning the active Argon2id params (`memoryCostKib` / `timeCost` / `parallelism`) plus the OWASP 2026 floor (`19 MiB` / `t>=2` / `p>=1`) plus `meetsFloor: bool`. Compliance-audit visibility without parsing PHC strings. Smoke 8458 → 8481 / wiki e2e 178 / Linux container smoke 8481 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
12
|
+
|
|
13
|
+
- **0.7.18** (2026-05-05) — transport-layer smuggling hardening (four ship-blocker fixes). **HTTP request-smuggling defense** in `b.middleware.bodyParser` per RFC 9112 §6.1: rejects requests with both `Content-Length` and `Transfer-Encoding` headers (CL.TE / TE.CL smuggling — CVE-2022-31394 / CVE-2024-27316 class), multiple `Content-Length` values, `Transfer-Encoding` whose final coding is not `chunked`, and duplicate `chunked` tokens (TE.TE smuggling). Each rejection responds 400 + `Connection: close` so the upstream proxy doesn't reuse the socket. **Static-serve symlink-escape + filename safety** in `b.staticServe`: `_resolveSafe` now `fs.realpathSync`-es the resolved path (defeats symlink-out-of-root) AND validates the basename through `b.guardFilename` at the balanced profile (rejects path traversal, null-byte, NTFS alternate data streams, UNC paths, RTLO bidi, overlong UTF-8, Windows reserved device names, double-extension; balanced profile chosen over strict so legitimate operator-deposited shell-exec extensions like `.exe`/`.bin` remain serveable). **Outbound SMTP smuggling defense** in `b.mail` SMTP transport: every produced RFC 822 wire (post-DKIM-sign) is run through `b.guardEmail.validateMessage` at strict profile before the socket opens; refuses on critical issues — bare CR / bare LF + smuggled SMTP verbs (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET class) cannot leave the framework even when operator-supplied subject/body/headers contain the pattern. **DKIM `l=` body-length tag forbidden** in `b.mail.dkim.create`: passing `bodyLength` now throws `dkim/l-tag-forbidden` at create-time. M³AAWG / Gmail / Microsoft 365 guidance is "never use l=" — it enables append-after-signature attacks where an attacker appends arbitrary content past the signed length and the DKIM signature still validates against the original prefix. The body is always hashed in full. Smoke 8450 → 8458 / wiki e2e 178 / Linux container smoke 8458 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
14
|
+
|
|
15
|
+
- **0.7.17** (2026-05-05) — `b.guardEmail` email content-safety primitive (single-address validation + full RFC 822 / 5322 message validation). Threat catalog grounded in current research (SMTP smuggling — CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET System.Net.Mail; SEC Consult / smtpsmuggling.com class; IDN homograph attacks; CRLF header injection). Surface: `validateAddress(addr, opts)` validates a single address; `validateMessage(rfc822, opts)` validates a full message; `validate(input, opts)` auto-detects; `sanitize(input, opts)` strips character-class threats but throws on critical (smuggling / CRLF injection / multi-@ / mixed-script — no safe sanitization for these); `gate(opts)` returns a `b.gateContract`-shaped gate auto-routed by `b.guardAll` for `message/rfc822` / `message/global`. KIND="content". **Threat catalog**: SMTP smuggling (bare CR / bare LF outside CRLF pairs combined with embedded SMTP verbs `MAIL FROM`/`RCPT TO`/`DATA`/`EHLO`/`HELO`/`RSET`/`QUIT`); CRLF header injection in single-line headers; IDN homograph spoofing (mixed-script Cyrillic / Greek / Armenian / Cherokee codepoints in domains — operator opts in via `allowedScripts`); Punycode/IDN flag; display-name spoofing (`"support@apple.com" <attacker@evil>` — display contains @-address that doesn't match envelope domain); IP literal addresses (`user@[1.2.3.4]` — bypasses DNS/DMARC alignment); RFC 5322 comment syntax in addresses; multiple @ characters; RFC 5321 length caps (local-part 64 / domain 255 / address 320); RFC 5322 line cap (998); BOM injection; bidi/null/control/zero-width chars in addresses + headers. **Profiles** — strict / balanced / permissive (SMTP smuggling + CRLF header injection + multi-@ + null bytes refused at every profile — universal class). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7. **Strict default-on via v0.7.12**: every `b.fileUpload` + `b.staticServe` deploy gets this gate at strict profile automatically. Smoke 8387 → smoke / wiki e2e / Linux container smoke + wiki e2e green / eslint clean / api-snapshot baseline refreshed.
|
|
16
|
+
|
|
17
|
+
- **0.7.16** (2026-05-05) — `b.guardMarkdown` markdown content-safety primitive. Threat catalog grounded in current research (CVE-2026-30838 CommonMark DisallowedRawHtml whitespace-tag bypass; CVE-2025-9540 Markup Markdown javascript: link XSS; CVE-2025-7969 markdown-it ReDoS; CVE-2025-6493 CodeMirror Markdown catastrophic backtracking; CVE-2025-24981 MDC autolink XSS; CVE-2026-33500 AVideo Parsedown link bypass; GHSA-gwjh-c548-f787 NuGetGallery autolink XSS; Joplin GHSA-hff8-hjwv-j9q7 RCE via untrusted markdown link). Surface: `validate(input, opts)` returns `{ ok, issues }`; `sanitize(input, opts)` strips character-class threats but throws on critical (dangerous tags / dangerous URL schemes / DOCTYPE / code-fence injection / image scheme / autolink scheme — no safe sanitization for these); `gate(opts)` returns a `b.gateContract`-shaped gate auto-routed by `b.guardAll` for `text/markdown` / `text/x-markdown` / `text/x-gfm`. KIND="content". Source-level discipline — runs BEFORE any downstream renderer (marked / markdown-it / commonmark / remark / parsedown) sees the input, the same shape that catches `__proto__` in JSON before parse. **Threat catalog**: dangerous tags (script/iframe/object/embed/form/input/meta/link/base/svg/math/video/audio/style/template/portal/marquee — refused at every profile, whitespace-tolerant per CVE-2026-30838); dangerous URL schemes in inline links + images + autolinks + reference-link definitions (javascript:/vbscript:/livescript:/mocha:/view-source:/data:/jar:/blob:/feed:/tel:/facetime: — refused at every profile); HTML-entity scheme bypass (`javascript:` / `javascript:` decoded BEFORE scheme matching); reference-link smuggling (`[label]: javascript:...` definition smuggle); autolink scheme bypass (`<javascript:alert(1)>`); front-matter YAML/TOML blocks; HTML comments; code-fence language injection (language tag containing `<>"' `` blocks attribute breakout); inline DOCTYPE; catastrophic emphasis runs (`*`/`_` of length 20+ — CVE-2025-6493 class); bidi/null/control/zero-width chars; total-bytes + line + link + image + autolink + ref-def + list-depth + blockquote-depth caps. **Profiles** — strict / balanced / permissive (dangerous tags + dangerous schemes + image schemes + autolink schemes refused at every profile — script-tag and javascript: are universal class). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7. **Strict default-on via v0.7.12**: every `b.fileUpload` + `b.staticServe` deploy gets this gate at strict profile automatically. Smoke 8320 → 8387 / wiki e2e 178 / Linux container smoke 8387 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
18
|
+
|
|
19
|
+
- **0.7.15** (2026-05-04) — `b.guardXml` XML content-safety primitive + smoke parallel mode + persistent test output. **`b.guardXml`** threat catalog grounded in current research (CVE-2026-24400 AssertJ XXE; CVE-2025-3225 sitemap parser; CVE-2024-1455 LangChain; CVE-2024-25062 libxml2 use-after-free with DTD + XInclude; CVE-2024-56171 + CVE-2025-24928 + CVE-2025-32415 + CVE-2025-27113 libxml2 family; CVE-2024-8176 libexpat stack overflow via recursive entity expansion). Surface: `validate(input, opts)` returns `{ ok, issues }`; `sanitize(input, opts)` strips character-class threats but throws on critical (DOCTYPE / ENTITY / external — no safe sanitization); `gate(opts)` returns a `b.gateContract`-shaped gate auto-routed by `b.guardAll` for `application/xml` / `text/xml`. KIND="content". **Threat catalog**: DOCTYPE refused unconditionally (XXE / billion-laughs vector); `<!ENTITY>` declarations including parameter entities (`%` prefix); external entity references (`SYSTEM`/`PUBLIC` with file://, http://, etc.); XInclude (`<xi:include>`); `xsi:schemaLocation` schema fetch; processing instructions (skipping standard `<?xml?>` declaration); CDATA sections; XML signature wrapping (audit); bidi/null/control/zero-width; element-count + depth caps; per-attribute-value-length cap. **Profiles** — strict / balanced / permissive (DOCTYPE refused at all profile levels — billion-laughs class is universal). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7. **Smoke parallel mode** — new `SMOKE_PARALLEL=N` env var forks Layer 0 test files in parallel batches (each fork is a fresh Node child process for module-state isolation). Layers 1-5 stay sequential because they share db / cluster / vault state. Sanity ceiling 64. Empirical: SMOKE_PARALLEL=64 takes 91s vs sequential 122s (25% faster). **Persistent test output** — `test/smoke.js`, `test/layer-0-primitives/codebase-patterns.test.js` (CLI mode), and `examples/wiki/test/e2e.js` now write a tee'd copy of all stdout/stderr to `.test-output/smoke.log` / `.test-output/codebase-patterns.log` / `.test-output/wiki-e2e.log`. Tee semantics — original stdout/stderr passthrough preserved so npm exit codes + GitHub Actions annotations work unchanged. `.test-output/` is auto-gitignored via the existing `.* ` catchall and never shipped (npm `files` allowlist explicit). **Family infrastructure** — guard-xml shares the same family-ABI shape (resolveProfileAndPosture / aggregateIssues / buildGuardGate / extractBytesAsText / etc.) as the other 6 family members; the v0.7.13 `family-subset` cluster allowlist sustains. Smoke 8264 → 8320 / wiki e2e 178 / Linux container smoke 8320 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
20
|
+
|
|
21
|
+
- **0.7.14** (2026-05-04) — `b.guardYaml` YAML content-safety primitive. Threat catalog grounded in current research (CVE-2026-24009 Docling/PyYAML unsafe load → RCE; CVE-2026-27807 MarkUs alias billion-laughs DoS; CVE-2025-68664 LangChain deserialization → RCE; CVE-2025-61301 + CVE-2025-61303 YAML library DoS family — "Laughter in the Wild" study; CVE-2022-1471 SnakeYAML constructor RCE; CVE-2020-1747 + CVE-2020-14343 PyYAML FullLoader; CVE-2017-18342 PyYAML python/object/apply). **Surface**: `validate(input, opts)` returns `{ ok, issues }`; `parse(input, opts)` refuses on critical and parses through the framework's safe-subset YAML parser; `gate(opts)` returns a `b.gateContract`-shaped gate auto-routed by `b.guardAll` for `application/yaml` / `text/yaml` content. Auto-registers into `b.guardAll` (KIND="content"). **Threat catalog**: deserialization-tag injection (language-specific tags `!!python/` / `!!java.` / `!!ruby/` / `!!perl/` / `!!js/` / `!!cs/` / `!!net/` / `!!system.` plus `!!eval` / `!!exec` / `!!new` / `!!apply`); custom + local user tags; anchor / alias recursion (billion-laughs amplification — `&anchor` declares + `*alias` references; alias-amplification ratio ≥8x flagged as alias-explosion regardless of absolute counts); multi-document streams (operators expecting one doc silently get the first one and ignore the rest); Norway problem (YAML 1.1 still default in pyyaml + libyaml in 2026 — unquoted no/yes/y/n/on/off treated as booleans, country code "NO" → false); leading-zero octals (`0777` parses as 511); duplicate keys (YAML 1.2 SHOULD-unique; parsers silently last-wins); merge-key chains (`<<: *anchor` anchor-chain DoS); bidi / null / control / zero-width chars in scalar values; total-size + node-count + anchor + alias-depth + document-count + scalar-length + depth caps. **Profiles** — strict (refuse every threat; 2 MiB cap; depth 8; 16 anchors; 1 alias depth; 1 document; 1024 nodes); balanced (audit most threats; allow YAML 1.2 core tags `!!str` / `!!int` / `!!seq` / `!!map` / etc.; refuse alias-explosion regardless; 8 MiB cap; 64 anchors); permissive (audit most; refuse only null bytes + alias-explosion; 64 MiB cap; 1024 anchors). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7 with strict overlays + forensic snapshots. **Refuse-only sanitize discipline**: YAML has no safe sanitization — gate decisions are serve / audit-only / refuse only. **Strict default-on via v0.7.12**: every `b.fileUpload` + `b.staticServe` deploy gets this gate at strict profile automatically. Smoke 8191 → 8264 / wiki e2e 178 (210 runtime examples clean) / Linux container smoke 8264 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
22
|
+
|
|
23
|
+
- **0.7.13** (2026-05-04) — `b.guardJson` JSON content-safety primitive. Threat catalog grounded in current research (CVE-2025-55182 React/Next.js Server Functions deserialization → RCE; CVE-2025-57820 + CVE-2026-30226 Svelte devalue prototype pollution; CVE-2026-35209 defu; CVE-2026-28794 @orpc/client; CVE-2025-13465 Lodash; CVE-2025-25014 Kibana RCE; CVE-2024-38984 json-override; CVE-2022-42743 deep-parse-json; GHSA-9c47-m6qq-7p4h JSON5). **Surface**: `validate(input, opts)` returns `{ ok, issues }`; `parse(input, opts)` returns the cleaned tree (composes `b.safeJson.parse` with the source-level pre-parse pollution scan); `gate(opts)` returns a `b.gateContract`-shaped gate auto-routed by `b.guardAll` for `application/json` / `application/ld+json` / `application/vnd.api+json` content. Auto-registers into `b.guardAll` (KIND="content"). **Threat catalog** — source-level prototype-pollution detection (catches `__proto__` / `constructor` / `prototype` keys BEFORE parse — JSON.parse silently routes `__proto__` through the prototype setter so a post-parse Object.keys walk misses the most dangerous key in the catalog); duplicate-key detection (RFC 8259 SHOULD-unique violation; JSON.parse silently last-wins, letting attackers smuggle duplicate-key payloads past validation that ran on the first occurrence — Bishop Fox JSON-interoperability research); NaN / Infinity / -Infinity / undefined refusal (RFC 8259 forbids; JSON5 / lenient libraries accept); comment refusal (single-line and block); trailing-comma refusal; JSON5 syntax refusal (single-quoted keys, hex literals, unquoted keys); BOM injection (leading + mid-stream); bidi / null / control / zero-width chars; numeric precision-loss (integers above `Number.MAX_SAFE_INTEGER`); top-level-key allowlist; depth + breadth + array-length + string-length + node-count caps. **Profiles** — strict (refuse every threat; 2 MiB doc cap; depth 8; 256 keys/object; 1024 array items) / balanced (strip pollution + BOM + bidi + control + null + zero-width; audit duplicates + comments + trailing commas + JSON5 + numeric precision; refuse NaN; 8 MiB cap; depth 32) / permissive (audit most; refuse only null bytes outright; 64 MiB cap; depth 64). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7 with strict overlays + forensic snapshots. **Strict default-on via v0.7.12**: every `b.fileUpload` + `b.staticServe` deploy gets this gate at strict profile automatically — no explicit wiring required. **Family infrastructure**: `KNOWN_CLUSTERS` allowlist gains `mode: "family-subset"` matcher (collapses 6+ exact-match cluster entries into one subset entry; sustainable as the family grows beyond 6 guards). Smoke 8108 → 8191 / wiki e2e 178 (210 runtime examples clean) / Linux container smoke 8191 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
24
|
+
|
|
25
|
+
- **0.7.12** (2026-05-04) — `b.fileUpload` and `b.staticServe` ship with `b.guardAll` wired ON by default at strict profile. Defense-in-depth applied automatically: every operator-supplied bytes path runs through the full guard-* family without any explicit wiring. **`b.fileUpload`**: `contentSafety` defaults to `b.guardAll.byExtension({ profile: "strict" })` (every shipped guard's full threat catalog refused — dangerous tags, event handlers, dangerous URL schemes, formula injection, DOCTYPE, SVGZ, animation-href hijack, zip-slip, ratio bombs); new `filenameSafety` opt defaults to `b.guardFilename.gate({ profile: "strict" })` (path traversal + null-byte truncation + Windows reserved names + NTFS ADS + RTLO bidi + overlong UTF-8 + shell-exec extensions + double-extension + reserved chars). **`b.staticServe`**: `contentSafety` defaults to `b.guardAll.byExtension({ profile: "strict" })`. **Explicit opt-out**: `contentSafety: null` / `filenameSafety: null` skips the gate AND emits an audit row at `create()` time (`fileUpload.contentSafety.disabled` / `fileUpload.filenameSafety.disabled` / `staticServe.contentSafety.disabled`) with operator-supplied `contentSafetyDisabledReason` / `filenameSafetyDisabledReason` metadata so a security review can reconstruct which deploys disabled the default-on protection AND why. **filenameSafety wiring**: runs in `fileUpload.finalize` BEFORE `contentSafety` (a refused filename obviates body validation); honours sanitize action (replaces `metadata.filename` with the cleaned form so downstream code sees the safe name). **Explicit operator wiring still works**: passing `contentSafety: { ".csv": gate }` or `filenameSafety: gate` uses the operator-supplied object as-is (no default-on override). **Migration**: this is a behavior change. Code that called `b.fileUpload.create({ stagingDir, onFinalize })` previously had no content / filename safety; after v0.7.12 the same call has both gates wired at strict profile. If existing routes depended on accepting hostile content (raw-bytes uploads, executable-extension artifacts), pass `contentSafety: null` and/or `filenameSafety: null` with an operator-supplied reason at `create()` time. Smoke 8101 → 8108 / wiki e2e 178 / Linux container smoke 8108 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
26
|
+
|
|
27
|
+
- **0.7.11** (2026-05-04) — adaptive guard-* family integration harness (`test/layer-5-integration/guard-host-integration.test.js`). The harness discovers every guard primitive registered in `b.guardAll.allGuards()` (registered content guards + standalone non-content guards) and exercises gate decisions through the appropriate host wiring per guard's `KIND`. **Adaptive design** — adding a new guard automatically picks up the harness without touching the test file. Each guard exports `KIND` (`"content"` / `"entries"` / `"filename"`) and `INTEGRATION_FIXTURES` with kind-appropriate sample payloads (benign + hostile). The harness iterates `b.guardAll.allGuards()`, dispatches per kind, and per-guard runs: direct gate (benign → serve; hostile → not serve); `b.guardAll.gate` contentTypeMux dispatch; `b.guardAll.gate({ exceptFor })` opt-out path with audit-row verification of the skipped roster; `b.staticServe.create({ contentSafety })` GET round-trip (benign → 2xx, hostile → 4xx); `b.fileUpload.create({ contentSafety })` chunk → finalize round-trip (benign succeeds, hostile throws content-safety error); audit chain captures host-level rows. **Family additions** — `lib/guard-all.js` gains `STANDALONE_GUARDS` array (currently `[guardFilename]`) and `allGuards()` aggregator that returns `GUARDS.concat(STANDALONE_GUARDS)`. Each guard module exports `KIND` + `INTEGRATION_FIXTURES`. **Tests run in-process** — no docker / network / fixture-archive dependencies; the harness completes in ~100ms regardless of guard count, so the gate runs on every smoke. Smoke 8060 → 8101 / wiki e2e 178 / Linux container smoke 8101 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed. **Future-proofing** — when v0.7.12+ adds guard-link / guard-mime / guard-yaml / etc., they ship with `KIND` + `INTEGRATION_FIXTURES` and the harness picks them up automatically. The on-by-default wiring slice (next) can flip the default knowing every shipped guard already has end-to-end host coverage.
|
|
28
|
+
|
|
29
|
+
- **0.7.10** (2026-05-04) — `b.guardArchive` archive content-safety primitive. Threat catalog grounded in current archive-extraction CVE research: CVE-2025-3445 mholt/archiver Zip Slip; CVE-2025-32779 EDDI Zip Slip; CVE-2025-62156 Argo Workflows Zip Slip; CVE-2025-66945 Zdir Pro path traversal; CVE-2025-45582 GNU Tar two-step symlink bypass; CVE-2025-11001/11002 7-Zip symlink + directory traversal RCE; CVE-2025-4138/4517 Python tarfile extraction-filter symlink bypass; CVE-2025-10854 txtai; CVE-2025-12060 Keras; CVE-2026-26960 node-tar hardlink-via-symlink-chain. **Surface**: `validateEntries(entries, opts)` validates an operator-supplied entry-list (`{ name, size, compressedSize, isSymlink, isHardlink, linkTarget, isDirectory, isEncrypted, attrs }`); `inspectMagic(buffer)` reads first bytes and returns detected format (zip / gzip / bzip2 / xz / 7z / rar4 / rar5 / lzma / zstd / tar via "ustar" magic at byte 257); `checkExtractionPath(entryName, extractionRoot)` single-entry helper; `gate(opts)` returns a `b.gateContract`-shaped gate. Auto-registers into `b.guardAll` (NAME / MIME_TYPES / EXTENSIONS / shared profiles + postures all conformant). **Scope discipline**: validates archive METADATA, not bytes. Operators use their archive library to enumerate entries, then validate the list before extracting. The framework's no-deps rule argues against shipping a parser for every format (zip / tar / gzip / bzip2 / 7z / rar / zstd...). **Threat catalog**: zip-slip path traversal (CVE-2025-3445 class); absolute-path entries (`/etc/passwd`, drive-letter prefix); symlink + hardlink escape (CVE-2026-26960 class); per-entry compression-ratio bombs (default 100:1 strict, 1000:1 permissive); aggregate compression-ratio bomb (sum-of-uncompressed / sum-of-compressed); total-size + entry-count + per-entry-size caps; nested-archive detection via extension catalog (.zip / .jar / .tar.gz / .tgz / .tar.bz2 / .gz / .bz2 / .xz / .7z / .rar / .zst / etc.) with maxNestedDepth; per-entry-name validation composing `b.guardFilename` (path traversal / null-byte / Windows reserved names / NTFS ADS / RTLO bidi / overlong UTF-8 / shell-exec / double-extension); duplicate entry names (silent overwrite vector); case-insensitive collisions on Windows / macOS; encryption-claim mismatch (mixed encrypted + unencrypted); sparse tar entries; format-claim mismatch via magic-byte detection. **Profiles**: strict (every threat refused; 100 entries; 100 MiB total; 100:1 ratio cap), balanced (symlinks within root allowed; no hardlinks; nested-depth 2; 10000 entries; 1 GiB total; 100:1/1000:1 ratios), permissive (symlinks + hardlinks within root; depth 4; 100000 entries; 10 GiB total; 1000:1 ratios). **Compliance postures**: hipaa / pci-dss / gdpr / soc2-cc7 with strict overlays + forensic snapshots. **Refuse-only sanitize discipline**: archive content has no safe sanitization — gate decisions are serve / audit-only / refuse only (no sanitize action). **Tests** — 102 new layer-0 assertions covering surface, registry parity, zip-slip detection (3 forms), absolute-path detection (3 forms), symlink reject (strict), symlink escape (balanced), hardlink reject + escape, compression-ratio bomb (per-entry), aggregate-ratio bomb, total-size cap, entry-count cap, nested-archive (.zip / .tar.gz), duplicate-entry-name, case-insensitive collision, encryption-claim mismatch, sparse entry, magic-byte detection (8 formats including tar via offset-257 ustar), checkExtractionPath helper, clean-archive + warn-only handling, gate decision shapes (clean / refuse / no-entry-list), profile + posture vocabulary. Smoke 7958 → 8060 / wiki e2e 178 (210 runtime examples clean) / Linux container smoke 8060 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
30
|
+
|
|
31
|
+
- **0.7.9** (2026-05-04) — `b.guardFilename` filename content-safety primitive. **Standalone primitive** — does NOT register into `b.guardAll`'s content-type-routed dispatch (filename is a different axis from content-bytes; operators apply both — filename safety on the upload's name plus content safety on the body). Threat catalog grounded in OWASP Path Traversal + WSTG file-inclusion testing guides; CWE-22 / CWE-23 / CWE-35 / CWE-73 / CWE-78 / CWE-434; PortSwigger File-path-traversal series (null-byte bypass + extension validation); Memento-RTLO + RTL-Spiegel file-name spoofing reports (CVE-2021-42574 in filename context); Kevin Boone overlong UTF-8 sequence write-up. **Threat catalog** — path traversal (raw + percent-encoded `%2e%2e` + double-encoded `%252e%252e` + UTF-8 overlong `0xC0 0xAE` for dot and `0xC0 0xAF` for slash); null-byte truncation; Windows reserved device names (CON / PRN / AUX / NUL / COM1-9 / LPT1-9 / CLOCK$ / CONFIG$, with and without extensions, case-insensitive); NTFS alternate data streams (`name:stream`); leading/trailing whitespace + trailing dots (Windows silently strips them); Unicode bidi / RTLO file-name spoofing; zero-width / homoglyph chars; reserved characters (Windows < > : " | ? *); UNC paths; path separators in leaf-name; length caps (strict 64-byte / balanced+permissive 255-byte); multi-dot policy (strict requires single dot, balanced+permissive allow .tar.gz); extension allowlist (catches double-extension bypass: `file.jpg.exe` matches `.exe` against the allowlist); shell-shortcut + executable extensions (.exe / .bat / .vbs / .ps1 / .lnk / .scr / .dll / .so / .dmg / .msi / etc); overlong UTF-8 detection at the Buffer level (RFC 3629 §3 prohibits non-shortest-form). **Profiles** — strict (ASCII-only, single dot, single leaf, 64-byte cap, every threat class refused), balanced (Unicode NFC-normalized, multi-dot allowed, 255-byte cap, refuses dangerous classes + strips zero-width + audits homoglyphs), permissive (multi-component paths up to 16 components, reserved-name audited not refused for non-Windows targets). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7 with appropriate strict overlays + ASCII-only + forensic snapshots. **Sanitize discipline** — strips leading/trailing whitespace + trailing dots, NFC-normalizes Unicode, replaces reserved chars with underscore (under strip policy), prepends underscore to Windows reserved device names. ALWAYS THROWS on path-traversal / null-byte / NTFS ADS / UNC / overlong UTF-8 — these have no safe sanitization, the only correct response is reject. **New shared helpers** — `gateContract.badInputResultIfNotStringOrBuffer(input)` and `gateContract.aggregateIssues(issues)` — extracted across guard-svg / guard-filename validate paths that need raw-Buffer input pre-conversion. Both registered in KNOWN_ANTIPATTERNS so future re-implementations fail the n=1 gate. **Tests** — 79 new layer-0 assertions covering surface, path traversal (5 forms), percent-encoded traversal (3 forms), null-byte truncation, every Windows reserved name (12 cases including extensions), NTFS ADS, leading/trailing strip, RTLO bidi spoofing, reserved chars (7 cases), UNC paths, path separators in leaf, length cap, single-dot policy, extension allowlist, shell-exec extensions (11 cases), double-extension bypass, overlong UTF-8 at buffer level, ASCII-only enforcement, sanitize round-trip + throw-on-traversal/null-byte, gate decision shapes, profile + posture vocabulary. Smoke 7879 → 7958 / wiki e2e 178 (206 runtime examples clean) / Linux container smoke 7958 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
32
|
+
|
|
33
|
+
- **0.7.8** (2026-05-04) — `b.guardSvg` SVG content-safety primitive + guard-* family helper extraction. **`b.guardSvg`** ships full v1 scope grounded in current SVG attack-surface research (Fortinet anatomy of SVG attack surface; Angular GHSA-jrmj-c5cx-3cw6 + GHSA-v4hv-rgfq-gp49 SVG animation/href XSS; SVGO CVE-2026-29074 billion laughs DoS; siyuan-note GHSA-5hc8-qmg8-pw27 animate-element sanitizer bypass; cure53/DOMPurify issue #233 xlink:href filtering; insertScript SVG fun-time series). Threat catalog: dangerous tags (script / foreignObject / handler / iframe / embed / object / audio / video / animation family); SMIL animation attributeName allowlist enforcement (recent CVE class — `<animate attributeName="href" to="javascript:..."/>` is the published bypass shape, refused regardless of broader animation policy); on* + SMIL event-handler attributes (onclick / onerror / onload / onbegin / onend / onrepeat); href + xlink:href dangerous URL schemes (javascript / vbscript / data outside image-context / file / mhtml / view-source — entity-encoded form too); cross-origin `<use>` external refs (SSRF + XSS chain); XML DOCTYPE declarations refused unconditionally (defends billion-laughs entity expansion + XXE); custom `<!ENTITY>` declarations; CDATA + processing instructions; SVGZ compressed payloads (gzip magic bytes 0x1F 0x8B refused at gate level — operator must ungzip first); CSS injection in style attributes; <use>-recursion DoS via maxUseDepth; total-document size + element-count + attribute-count caps. **Profiles**: strict (minimal text+shapes; no animation; no external refs), balanced (adds <use> + <image> with same-origin or http(s); allowExternalRefs=true), permissive (adds animation with attributeName allowlist still enforced). **Compliance postures**: hipaa / pci-dss / gdpr / soc2-cc7 with strict overlays + forensic snapshots. **Guard-* family helper extraction (audit-existing-code rule §8 sweep)** — five new shared helpers landed in `lib/codepoint-class.js` and `lib/gate-contract.js` and the existing guards refactored to consume them: `codepointClass.detectCharThreats(text, opts, codePrefix)`, `codepointClass.assertNoCharThreats(text, opts, errorFactory, codePrefix)`, `codepointClass.applyCharStripPolicies(text, opts)`, `gateContract.resolveProfileAndPosture(opts, cfg)`, `gateContract.runIssueValidator(input, opts, detector)`, `gateContract.buildGuardGate(name, opts, check)`, `gateContract.extractBytesAsText(ctx)`, `gateContract.lookupCompliancePosture(name, postures, errorFactory, codePrefix)`, `gateContract.makeRulePackLoader(errorClass, codePrefix)`, `gateContract.makeProfileBuilder(profiles)`, `numericBounds.requireAllPositiveFiniteIntIfPresent(opts, names, labelPrefix, errorClass, code)`. Pre-existing call sites in `lib/csv.js` and `lib/file-upload.js` migrated in the same patch per the no-future-patch-deferrals rule. New shared module `lib/codepoint-class.js` centralizes BIDI / C0_CTRL / ZERO_WIDTH range tables plus the `_hex4` / `_charClass` / `_fromCp` rendering helpers — the codepoint catalog has a single source of truth and future guard-* slices consume the shared module instead of redeclaring the tables. Each helper extraction registers its inline-shape in `KNOWN_ANTIPATTERNS` (the codebase-patterns gate fires at n=1 if any future code re-introduces the pattern). **Tests** — 83 new layer-0 assertions covering surface, registry parity, dangerous-tag detection, on* handler family, dangerous URL schemes (entity-encoded form too), animation attributeName href hijack, cross-origin <use> refusal, DOCTYPE / <!ENTITY> rejection, CDATA + processing-instruction policy, SVGZ magic-byte detection, CSS injection, bidi/control/null-byte detection, depth/size caps, sanitize round-trips, gate decision shapes, profile + posture vocabulary. Smoke 7796 → 7879 / wiki e2e 178 (203 runtime examples clean) / Linux container smoke 7879 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
34
|
+
|
|
35
|
+
- **0.7.7** (2026-05-04) — `b.guardHtml` HTML content-safety primitive ships full v1 scope. Threat catalog grounded in current sanitizer research (DOMPurify CVE series, OWASP XSS / DOM-Clobbering / HTML5 Security cheat sheets, PortSwigger and Sonar mXSS write-ups, html5sec.org). Surface: `validate(input, opts)` → `{ ok, issues }`; `sanitize(input, opts)` → cleaned HTML string; `escapeText(value)` and `escapeAttr(value)` always-correct entity escapers; `gate(opts)` returns a `b.gateContract`-shaped gate; `buildProfile`, `compliancePosture`, `loadRulePack`. Auto-registers into `b.guardAll` (NAME / MIME_TYPES / EXTENSIONS / shared profiles + postures all conformant). **Threat catalog covered** — dangerous tags (script / style / link / meta / base / iframe / object / embed / applet / form / input / button / frame / frameset / marquee / blink / plaintext / xmp / audio / video / source / track / math / svg / template / noscript / portal / dialog / keygen / menuitem / command); every `on*` event-handler attribute (caught family-wide via `/^on[a-z]/` regex — covers the entire HTML5 event-handler family without a manual list that rots when WHATWG specs a new event); form-override attributes (formaction / formmethod / formenctype / formtarget / formnovalidate, CWE-1021); iframe `srcdoc`; custom-element `is`; CSP-bypass-shaped attributes (nonce / integrity / crossorigin / http-equiv / manifest); URL-scheme allowlist with entity-decode pre-pass (defends `javascript:` and decimal-entity bypasses); CSS injection in style attribute values (expression / behavior: / -moz-binding / javascript:/vbscript: inside url() / @import / @namespace); DOM clobbering (id/name attribute values matching well-known JS globals on form/input/button/a/img/iframe/object/embed/select/textarea); mXSS hints (svg/math namespace-context-shift parents); IE conditional comments; Unicode bidi override (CVE-2021-42574 Trojan Source) / C0 control chars / null bytes / zero-width chars; tag-depth + attribute-count + per-attribute-value-size + total-document-size DoS caps. **Profiles** — strict (minimal text-formatting allowlist; reject every threat class) / balanced (links + images + tables + semantic markup; strip rather than reject for character-class threats; data:image/* on `<img>`) / permissive (every tag NOT in the dangerous-tag denylist). **Compliance postures** — hipaa / pci-dss / gdpr / soc2-cc7 with appropriate strict/balanced overlays + forensic-snippet sizing (256 / 256 / 128 / 512 bytes). **Sanitizer discipline** — drops dangerous tags AND their text-content body (script/style/iframe/object/embed/applet/template/svg/math etc. — body parsed as code in the host); strips on* attributes, dangerous URL schemes, CSS injection patterns, DOM-clobbering values, doctypes, CDATA, comments. Documented operator-facing trade-off: for hostile sources, validate+reject is the strong path; sanitize is best-effort and operators displaying untrusted HTML should additionally serve under a strict CSP. **Codepoint-table programmatic regex pattern** — same as guard-csv: BIDI_RANGES / C0_CTRL_RANGES / ZERO_WIDTH_RANGES literal numeric tables compiled into character classes via `_charClass` + `\\uXXXX` escapes at module load. Source file never embeds attack chars themselves. **Tests** — 134 new layer-0 assertions covering surface, registry parity, every dangerous tag in the denylist, 15 representative on* handlers, 9 dangerous attributes, 10 dangerous URL schemes (entity-encoded form too), 5 CSS injection patterns, 8 DOM clobber globals, mXSS hints, IE conditional comments, bidi/control/null detection, depth/size caps, sanitize round-trips, escape correctness, gate decision shapes (clean/refuse/sanitize), profile + posture vocabulary. Smoke 7662 → 7796 / wiki e2e 178 (200 runtime examples clean) / Linux container smoke 7796 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
36
|
+
|
|
37
|
+
- **0.7.6** (2026-05-04) — `b.guardAll` registry + aggregator for the guard-* family. Security-on-by-default: every shipped guard is ON unless the operator opts OUT specifically with an audited reason. **Surface** — `b.guardAll.gate(opts)` returns a single `b.gateContract`-shaped gate that routes by `Content-Type` to the right registered guard; `b.guardAll.byExtension(opts)` and `b.guardAll.byContentType(opts)` return ready-made gate maps for direct drop-in to `b.staticServe.create({ contentSafety: ... })` and any host primitive that dispatches on extension or mime; `b.guardAll.list()` returns the registered roster (operator audit aid); `b.guardAll.GUARDS` / `SHARED_PROFILES` / `SHARED_POSTURES` exposed as readonly registry exports. **Opt-out** — `exceptFor: { csv: { reason: "no CSV emission in this app" } }` requires a non-empty `reason` string per opted-out guard (throws at gate-creation time, not silently); `override: { csv: { profile: "email-attachment" } }` reaches per-guard extension profiles that aren't in the shared vocabulary. **Audit emission** — every gate / byExtension / byContentType call with `opts.audit` wired emits `guardAll.gate.created` exactly once at gate-creation time, recording the full `active` + `skipped` roster (each skipped entry carries its `reason`). **Registry contract** — every primitive registered into `b.guardAll` MUST export `NAME` (string), `MIME_TYPES` (frozen array), `EXTENSIONS` (frozen array), `PROFILES` containing the shared vocabulary (`strict` / `balanced` / `permissive`), `COMPLIANCE_POSTURES` containing the shared regulatory shapes (`hipaa` / `pci-dss` / `gdpr` / `soc2-cc7`), and `gate(opts)` returning a `b.gateContract`-shaped gate. The parity check at module load throws `GuardAllError` with a multi-line failure list if any registered guard is missing the contract — this is the framework's mechanism for keeping every future guard slice conformant. Duplicate `NAME` / `MIME_TYPE` / `EXTENSION` across guards is also caught at module load. **Adjacent** — `b.guardCsv` now exports `NAME` / `MIME_TYPES` / `EXTENSIONS` for registry compliance. Wiki harness gains `fs` binding for examples that mkdir staging dirs (joins the existing `path` / `os` bindings). **Tests** — 56 new layer-0 assertions: surface, registry parity (every member declares the full contract), default-on (no `exceptFor` → every guard active), `exceptFor` reason validation (missing / blank / non-object / unknown name throws), `override` shape validation, profile-vocabulary enforcement (per-guard extensions like csv's `email-attachment` rejected at the aggregator), posture-vocabulary enforcement, audit creation roster, dispatch correctness (text/csv routes to csv guard, unrelated mime bypasses), byExtension / byContentType output shape. Smoke 7606 → 7662 / wiki e2e 178 (196 runtime examples clean) / Linux container smoke 7662 / Linux container wiki e2e 178 / eslint clean / shellcheck clean / api-snapshot baseline refreshed.
|
|
38
|
+
|
|
39
|
+
- **0.7.5** (2026-05-04) — `b.gateContract` foundation + `b.guardCsv` content-safety primitive. **BREAKING (pre-v1):** `b.csv.stringify` no longer accepts `preventFormulaInjection` / `formulaPrefixChars` opts — the partial single-mode defense it offered (only `=`/`+`/`-`/`@`/TAB/CR ASCII prefixes, missing LF / `|` / full-width formula chars) was a false-confidence path. `b.csv` is now documented as trusted-source-only emission (RFC 4180 quoting + anti-DoS bounds); any user-supplied cells route through `b.guardCsv.serialize` / `.gate` for the full threat catalog. The `b.guardCsv.parse` / `.stringify` re-exports are also removed — operators use `b.csv.parse` / `.stringify` directly for pure parsing. New `guard-*` family naming complements the existing `safe-*` parser family (safeJson / safeUrl / safeBuffer / safeSql / safeAsync are "parser/validator with safer defaults"; guard-* are content-safety gates that run inside the request lifecycle). **`b.gateContract`** provides the uniform composition contract every guard-* primitive implements: `defineGate / runGate / composeGates / multiplexGates / contentTypeMux / shadowMode / canaryGate / cachingGate / workerThreadGate / buildProfile / composeHooks / summarizeIssues / validateGateShape`, plus mode posture (enforce / warn-only / shadow / audit-only / log-only / canary), hook system (beforeCheck / afterCheck / onIssue / onSanitize / onRefuse / onAudit), forensic snapshot store integration, decision cache, and runtime cap via `safeAsync.withTimeout`. **`b.guardCsv`** ships full v1-defensible scope: `serialize / validate / sanitize / escapeCell / detect / parse / stringify / schema / gate / buildProfile / compliancePosture / loadRulePack`. Threat catalog covered — formula injection (5 modes: prefix-tab / prefix-quote / wrap-with-quotes-and-prefix / reject / allowlist) with all 8 ASCII triggers (`= + - @ TAB CR LF |`) plus full-width variants (U+FF1D / U+FF0B / U+FF0D / U+FF20) per OWASP locale catalog, dangerous-function denylist (WEBSERVICE / HYPERLINK / IMAGE / IMPORT* / RTD / DDE / CALL / GOOGLEFINANCE / GOOGLETRANSLATE) surfaced as critical regardless of broader formula policy, Unicode bidi override (CVE-2021-42574 Trojan Source), homoglyph mixed-script detection, C0 control chars, null bytes, BOM mid-stream injection, zero-width chars, dialect ambiguity, CSV-bombs (per-cell / total / sanitize-amplification caps), numeric precision loss above `Number.MAX_SAFE_INTEGER`, trailing-whitespace exfiltration policy, PII redaction (composes `b.redact`), and schema-bound serializer with type / regex / range / nullable validation. Profiles: `strict` (OWASP-recommended `prefix-tab` default — Excel-resistant) / `balanced` / `permissive` / `email-attachment`; compliance postures: `hipaa` / `pci-dss` / `gdpr` / `soc2-cc7`. **Threat-detection regex literals are composed programmatically from numeric codepoint range tables** (`BIDI_RANGES` / `C0_CTRL_RANGES` / `ZERO_WIDTH_RANGES` / `HOMOGLYPH_RANGES` / `FORMULA_PREFIX_CPS`) — `_charClass(ranges)` renders into a regex character class via `\uXXXX` escapes at module load. Source file never embeds the attack characters themselves, only their codepoint numbers; the detector composes the way an attacker would compose the payload. **Adjacent integrations** — `b.staticServe` gains `contentSafety: { ".csv": gate, ... }` extension-keyed gate map (runs after MIME allowlist / before headers; `sanitize` decision replaces body buffer, `refuse` returns 415); `b.fileUpload` gains `contentSafety: gate` running before `onFinalize` (`refuse` throws `CONTENT_SAFETY_REFUSED`, `sanitize` replaces `bodyBuffer`). **Validation primitive** — `validateOpts.optionalPlainObject(value, label, ErrorClass, code, description)` consolidates the `if (typeof !== "object" || Array.isArray) throw` cascade across api-key / db-declare-view / static / file-upload; registered in `KNOWN_ANTIPATTERNS` so future re-implementations fail the n=1 gate. **Tests** — 47 new layer-0 assertions covering surface (gateContract + guardCsv), every formula prefix per policy, full-width formula prefixes, dangerous-function denylist, bidi reject + strip, control-char + null byte, numeric precision, CSV-bombs, dialect detection, sanitize amplification cap, schema type drift / nullable / regex, gate clean serve / refuse / sanitize, operator rules, forensic snapshot, audit emission. Smoke 7485 → 7614 / wiki e2e 178 / Linux container smoke 7614 (156s) / Linux container wiki e2e 178 / eslint clean / shellcheck clean / api-snapshot baseline refreshed for new public surface.
|
|
40
|
+
|
|
11
41
|
- **0.7.4** (2026-05-04) — CI gate alignment, wiki cross-platform fix, redis-client opts forwarding consolidation. Two CI-only gates (`scripts/check-api-snapshot.js` for public-API drift detection; Linux-container wiki e2e for cross-platform example execution) added to the local Release Workflow, plus `api-snapshot.json` baseline refreshed for the first time since v0.6.17 (40+ patches of accumulated drift; `b.objectStoreRetry` removal in v0.7.0 was the BREAKING entry). The wiki file-upload example previously hardcoded `stagingDir: "/var/lib/myapp/uploads"` which mkdir-passed on Windows but failed `EACCES` on Linux/macOS CI runners — replaced with a real runnable example using `path.join(os.tmpdir(), "myapp-uploads-" + Date.now())` and a self-contained `b.fileUpload.create` + `uploads.close()` round-trip; the Usage block likewise instantiates a real `b.router.create()` + uploads pair. `examples/wiki/test/run-example.js` harness gains `path` + `os` Node built-ins as bindings so wiki examples can write idiomatic `path.join(os.tmpdir(), ...)` without falling foul of the harness's `var X = require(...)` line stripper. **`redisClient.pickClientOpts(cfg, prefix?)`** — new helper that returns the standard 9-key opts bag (`url / password / username / tls / ca / servername / connectTimeoutMs / commandTimeoutMs / maxReconnectAttempts`); `cache-redis` / `pubsub-redis` / `queue-redis` / `cache` redis-backend creator all migrated to it, eliminating ~40 lines of duplicate inline forwarding across the four files. The new `inline-redis-client-opts-forwarding` `KNOWN_ANTIPATTERNS` entry catches future re-implementations at n=1. **`testNoDuplicateCodeBlocks`** + **`testNoUnresolvedMarkers`** + **`testNoTierTerminologyInLib`** detector tuning: `MIN_DISTINCT_FILES` lowered 3 → 2 / `MIN_DISTINCT_TOKENS` lowered 6 → 5 (more aggressive duplicate detection); `defer` added to forbidden-marker scan; `Tier 1/2/3` added to forbidden-tier-terminology scan. `lib/log-stream-syslog.js`'s socket error-handler comment reworded from "defer to 'close' for reconnect" to "reconnect handled in the close listener" — same behavior, no marker false-positive. **Tests** — Linux-container wiki e2e green (188 examples ran clean, 0 failures, 0 symbol drift); `node scripts/check-api-snapshot.js` reports `[api-snapshot] no changes`. Smoke 7485 / wiki e2e 178 / Linux container smoke 7485 / Linux container wiki e2e 178 / eslint clean / shellcheck clean.
|
|
12
42
|
|
|
13
43
|
- **0.7.3** (2026-05-04) — `b.middleware.apiEncrypt` per-session keying mode (opt-in). Per-request keying stays the default — every existing test passes unchanged. Operators opt in by passing `keying: "per-session"`; the first request in a session carries the bootstrap envelope `{ _ek, _ct, _ts, _nonce, _sid, _ctr }` and the server stores the session key keyed by `_sid` (UUID v4); subsequent requests in the same session omit `_ek` and `_nonce` entirely, sending only `{ _ct, _ts, _sid, _ctr }`. The monotonic `_ctr` replaces the nonce for replay defense — server rejects any request with `_ctr <= last_seen_ctr` for that session. Responses carry `{ _ct, _sid, _ctr }`; the client helper validates the response counter is strictly increasing so a tampered / replayed response surfaces as `CLIENT_RESPONSE_REPLAY` instead of silently propagating stale data. Sessions expire on `sessionTtlMs` (default 15 minutes) OR after `sessionMaxResponses` (default 1024); both surface as 401 (`session-expired` / `session-rotation-required`) and the client helper's `resetSession()` re-bootstraps. Operator-supplied `sessionStore` is a `{ get, set, delete }`-shaped handle (b.cache shape-compatible) so multi-replica deploys share session state; default is an in-memory map for single-process apps. Wire format trade-off: per-session amortizes ~1.6 KB of KEM material across the session for high-throughput / mobile / metered-network apps; per-request stays strictly safer if the session storage gets compromised. Audit emits `apiEncrypt.session.created` / `.expired` / `.rotated` / `.replay_rejected` with actor context; observability counters track sessions established / replay-rejected / expired / unknown / rotated. The framework HTTP-client helper `b.httpClient.encrypted({ keying: "per-session" })` threads the same posture through service-to-service callers. **Tests** — 13 new layer-0 assertions covering default-keying invariant, opt validation (bad keying value / TTL / store shape), bootstrap-and-reuse round-trip (KEM amortization confirmed by inspecting subsequent envelope shape), unknown-sid 401, counter-replay 400, TTL-expiry 401, max-responses-rotation 401, response counter monotonic check, sessionInfo / resetSession client API, observability emission. Smoke 7445 → 7485 / wiki e2e 178 / Linux container smoke 7485 (147s) / eslint clean / shellcheck clean.
|
package/README.md
CHANGED
|
@@ -46,6 +46,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
46
46
|
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA that issues clientAuth / serverAuth / dual-EKU certs with SAN entries and auto-detects the highest-PQC signature algorithm the vendored x509 library accepts (today: ECDSA-P384-SHA384 bridge; self-upgrades to SLH-DSA / ML-DSA when the X.509 ecosystem catches up), PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
47
|
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate (cloud-metadata IPs hard-denied unconditionally; private / loopback / link-local overridable per call), scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), IPv4-or-IPv6 NTP servers, DNS with IPv6 / DoH / DoT (private-CA trust pinning via `opts.ca`) / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
|
|
48
48
|
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
|
|
49
|
+
- **Content-safety gates** — `b.gateContract` uniform composition contract (mode posture / hooks / forensic snapshot / decision cache / runtime cap). Family members: `b.guardCsv` (formula injection ASCII + full-width prefixes, dangerous-function denylist, bidi / homoglyph / control / null / BOM / zero-width detection, dialect ambiguity, CSV-bombs, numeric precision, schema-bound serializer); `b.guardHtml` (XSS / mXSS / DOM-clobbering / dangerous-tag / event-handler-family / dangerous-URL-scheme with entity-decode / CSS-injection in style attribute / IE conditional comments + token-level sanitize + always-correct `escapeText` / `escapeAttr` entity encoders); `b.guardSvg` (script / foreignObject / animation-element href hijack / DOCTYPE billion-laughs / XXE / SVGZ / cross-origin `<use>` SSRF / event-handlers + token-level sanitize). `b.guardArchive` (zip-slip / symlink + hardlink escape / decompression-ratio bombs (per-entry + aggregate) / nested-archive depth / duplicate-entry / case-insensitive collision / encryption-claim mismatch / format-claim mismatch via magic-byte detection — composes `b.guardFilename` for per-entry-name validation); `b.guardJson` (source-level prototype-pollution detection, duplicate-key, NaN/Infinity, JSON5 syntax, BOM, bidi, numeric-precision-loss, top-level-key allowlist, depth/breadth/array/string/node-count caps); `b.guardYaml` (deserialization-tag RCE via language-specific tag prefixes, billion-laughs alias recursion, Norway-problem implicit booleans, leading-zero octals, multi-document streams, duplicate keys, merge-key chains, depth+anchor+node caps); `b.guardXml` (XXE / billion-laughs / external-entity / parameter-entity / XInclude / xsi:schemaLocation / processing-instruction / CDATA / XML-signature-wrapping detection — DOCTYPE refused at all profile levels); `b.guardMarkdown` (source-level scan run BEFORE any markdown renderer sees the input — dangerous URL schemes in inline links + images + autolinks + reference-link definitions with HTML-entity decode bypass; whitespace-tolerant dangerous-tag matching per CVE-2026-30838; front-matter; HTML comments; code-fence language injection; catastrophic emphasis-run ReDoS per CVE-2025-6493 class; inline DOCTYPE; depth + link + image + autolink + ref-def caps); `b.guardEmail` (single-address + full RFC 822/5322 message validation — SMTP smuggling per CVE-2023-51764 / 51765 / 51766 / CVE-2026-32178 class via bare-CR + bare-LF + smuggled SMTP verbs; CRLF header injection; IDN homograph mixed-script domains with operator-opt-in `allowedScripts`; Punycode flag; display-name spoofing; IP-literal addresses; RFC 5322 comment syntax; multi-@; RFC 5321 length caps + RFC 5322 line cap; BOM injection). Filename safety: `b.guardFilename` (path traversal raw + percent-encoded + overlong-UTF-8 + null-byte truncation + Windows reserved names + NTFS ADS + RTLO bidi spoofing + shell-exec / double-extension detection — standalone, wires into `b.fileUpload` via `filenameSafety`). All members ship strict / balanced / permissive profiles plus hipaa / pci-dss / gdpr / soc2-cc7 compliance postures. `b.guardAll` is the registry + aggregator: every shipped guard ON by default; opt-out per guard with audited reason via `exceptFor: { name: { reason } }`. **As of v0.7.12, `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` automatically + `b.fileUpload` also wires `b.guardFilename.gate({ profile: "strict" })` as `filenameSafety`** — defense-in-depth applied without any explicit operator wiring. Operators opt out per host-primitive via `contentSafety: null` / `filenameSafety: null` (audited at create() with operator-supplied reason).
|
|
49
50
|
- **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); generic distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`); chunked file uploads with per-chunk SHA3-512 verification + atomic finalize + tombstone cleanup (`b.fileUpload`).
|
|
50
51
|
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON OR OTLP/gRPC to an OTel collector, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
|
|
51
52
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`).
|
package/index.js
CHANGED
|
@@ -83,13 +83,27 @@ httpClient.encrypted = require("./lib/middleware/api-encrypt").httpClient;
|
|
|
83
83
|
httpClient.cookieJar = require("./lib/http-client-cookie-jar");
|
|
84
84
|
var websocket = require("./lib/websocket");
|
|
85
85
|
var safeUrl = require("./lib/safe-url");
|
|
86
|
+
var gateContract = require("./lib/gate-contract");
|
|
87
|
+
var guardCsv = require("./lib/guard-csv");
|
|
88
|
+
var guardHtml = require("./lib/guard-html");
|
|
89
|
+
var guardSvg = require("./lib/guard-svg");
|
|
90
|
+
var guardFilename = require("./lib/guard-filename");
|
|
91
|
+
var guardArchive = require("./lib/guard-archive");
|
|
92
|
+
var guardJson = require("./lib/guard-json");
|
|
93
|
+
var guardYaml = require("./lib/guard-yaml");
|
|
94
|
+
var guardXml = require("./lib/guard-xml");
|
|
95
|
+
var guardMarkdown = require("./lib/guard-markdown");
|
|
96
|
+
var guardEmail = require("./lib/guard-email");
|
|
97
|
+
var guardAll = require("./lib/guard-all");
|
|
86
98
|
var ssrfGuard = require("./lib/ssrf-guard");
|
|
87
99
|
var authHeader = require("./lib/auth-header");
|
|
88
100
|
var auth = {
|
|
89
101
|
password: require("./lib/auth/password"),
|
|
90
102
|
totp: require("./lib/totp"),
|
|
91
103
|
passkey: require("./lib/auth/passkey"),
|
|
92
|
-
jwt:
|
|
104
|
+
jwt: Object.assign({},
|
|
105
|
+
require("./lib/auth/jwt"),
|
|
106
|
+
{ verifyExternal: require("./lib/auth/jwt-external").verifyExternal }),
|
|
93
107
|
oauth: require("./lib/auth/oauth"),
|
|
94
108
|
lockout: require("./lib/auth/lockout"),
|
|
95
109
|
};
|
|
@@ -206,6 +220,18 @@ module.exports = {
|
|
|
206
220
|
httpClient: httpClient,
|
|
207
221
|
websocket: websocket,
|
|
208
222
|
safeUrl: safeUrl,
|
|
223
|
+
gateContract: gateContract,
|
|
224
|
+
guardCsv: guardCsv,
|
|
225
|
+
guardHtml: guardHtml,
|
|
226
|
+
guardSvg: guardSvg,
|
|
227
|
+
guardFilename: guardFilename,
|
|
228
|
+
guardArchive: guardArchive,
|
|
229
|
+
guardJson: guardJson,
|
|
230
|
+
guardYaml: guardYaml,
|
|
231
|
+
guardXml: guardXml,
|
|
232
|
+
guardMarkdown: guardMarkdown,
|
|
233
|
+
guardEmail: guardEmail,
|
|
234
|
+
guardAll: guardAll,
|
|
209
235
|
ssrfGuard: ssrfGuard,
|
|
210
236
|
authHeader: authHeader,
|
|
211
237
|
auth: auth,
|
package/lib/api-key.js
CHANGED
|
@@ -164,11 +164,8 @@ function _validateIssueOpts(opts) {
|
|
|
164
164
|
validateOpts.requireObject(opts, "apiKey.issue", ApiKeyError);
|
|
165
165
|
validateOpts.requireNonEmptyString(opts.ownerId, "apiKey.issue: ownerId", ApiKeyError, "MISSING_OWNER");
|
|
166
166
|
validateOpts.optionalNonEmptyStringArray(opts.scopes, "apiKey.issue: scopes", ApiKeyError, "BAD_SCOPES");
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
throw _err("BAD_METADATA", "apiKey.issue: metadata must be a plain object or null");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
167
|
+
validateOpts.optionalPlainObject(opts.metadata, "apiKey.issue: metadata",
|
|
168
|
+
ApiKeyError, "BAD_METADATA");
|
|
172
169
|
if (opts.expiresAt !== undefined && opts.expiresAt !== null) {
|
|
173
170
|
if (typeof opts.expiresAt !== "number" || !isFinite(opts.expiresAt) || opts.expiresAt < 0) {
|
|
174
171
|
throw _err("BAD_OPT", "apiKey.issue: expiresAt must be a non-negative finite number (unix ms) or null");
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* jwt-external — verify JWTs signed by an external IdP using classical
|
|
4
|
+
* algorithms (RS256 / RS384 / RS512 / PS256 / PS384 / PS512 / ES256 /
|
|
5
|
+
* ES384 / ES512 / EdDSA).
|
|
6
|
+
*
|
|
7
|
+
* Distinct from `b.auth.jwt.verify` which is PQC-only (ML-DSA-65 etc.).
|
|
8
|
+
* Operators integrating with Auth0 / Okta / Keycloak / AWS Cognito /
|
|
9
|
+
* Azure AD / Google IdP / Apple-sign-in use this primitive — those IdPs
|
|
10
|
+
* sign with classical algorithms and the framework's PQC verifier
|
|
11
|
+
* cannot accept their tokens.
|
|
12
|
+
*
|
|
13
|
+
* var rv = await b.auth.jwt.verifyExternal(token, {
|
|
14
|
+
* algorithms: ["RS256", "ES256"], // REQUIRED — no defaults
|
|
15
|
+
* jwks: jwksKeysArray, // pre-fetched RFC 7517 keys
|
|
16
|
+
* // OR
|
|
17
|
+
* jwksUri: "https://example.auth0.com/.well-known/jwks.json",
|
|
18
|
+
* jwksCacheMs: C.TIME.minutes(10), // default 10m
|
|
19
|
+
* // OR
|
|
20
|
+
* keyResolver: async function (header) { return jwkOrKeyObject; },
|
|
21
|
+
*
|
|
22
|
+
* audience: "api://my-api", // optional but recommended
|
|
23
|
+
* issuer: "https://example.auth0.com/", // optional but recommended
|
|
24
|
+
* subject: "user@example.com", // optional sub-equality check
|
|
25
|
+
* clockSkewMs: 30 * 1000, // default 30s tolerance
|
|
26
|
+
* });
|
|
27
|
+
* // → { header, claims } (throws AuthError on any failure)
|
|
28
|
+
*
|
|
29
|
+
* Defenses against the well-known JWT pitfalls:
|
|
30
|
+
*
|
|
31
|
+
* - alg confusion (CVE-2024-54150 / CVE-2025-30144 / CVE-2026-22817
|
|
32
|
+
* Hono class) — `algorithms` is REQUIRED with no default; `none`,
|
|
33
|
+
* `HS256` cannot be accepted unless the operator explicitly listed
|
|
34
|
+
* them, and even then the verifier refuses HS* algs in
|
|
35
|
+
* verifyExternal because HMAC + a public-key JWKS is the canonical
|
|
36
|
+
* alg-confusion shape. Operators with HMAC need a different path.
|
|
37
|
+
* - kid spoofing — the resolved key MUST come from the operator's
|
|
38
|
+
* trust source (the JWKS array or operator's keyResolver). The
|
|
39
|
+
* header's `kid` only selects WHICH key from that source.
|
|
40
|
+
* - exp/nbf/iat — checked against now (with clockSkewMs tolerance).
|
|
41
|
+
* - aud / iss / sub — checked when the operator passes the expected
|
|
42
|
+
* value. iss MUST match exactly (not substring).
|
|
43
|
+
* - JWKS endpoint trust — `jwksUri` resolves through the framework's
|
|
44
|
+
* `b.httpClient` (SSRF gate, TLS-required by default, response-size
|
|
45
|
+
* cap). The jwksCache is per-process and TTL-bounded.
|
|
46
|
+
*
|
|
47
|
+
* Returns { header, claims } on success. Every failure surfaces as
|
|
48
|
+
* AuthError with a code in `auth-jwt-external/<reason>` so operators
|
|
49
|
+
* can route alerts on a single class.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
var nodeCrypto = require("crypto");
|
|
53
|
+
var safeJson = require("../safe-json");
|
|
54
|
+
var safeUrl = require("../safe-url");
|
|
55
|
+
var lazyRequire = require("../lazy-require");
|
|
56
|
+
var validateOpts = require("../validate-opts");
|
|
57
|
+
var C = require("../constants");
|
|
58
|
+
var { AuthError } = require("../framework-error");
|
|
59
|
+
|
|
60
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
61
|
+
var cache = lazyRequire(function () { return require("../cache"); });
|
|
62
|
+
|
|
63
|
+
// ---- constants ----
|
|
64
|
+
|
|
65
|
+
var DEFAULT_CLOCK_SKEW_MS = C.TIME.seconds(30);
|
|
66
|
+
var DEFAULT_JWKS_CACHE_MS = C.TIME.minutes(10);
|
|
67
|
+
var MAX_JWKS_BYTES = C.BYTES.kib(64);
|
|
68
|
+
var MAX_TOKEN_BYTES = C.BYTES.kib(16);
|
|
69
|
+
|
|
70
|
+
// HMAC-shaped algs (HS256/384/512) and "none" are NEVER accepted by
|
|
71
|
+
// this primitive. HMAC + a JWKS-shaped public-key trust source is the
|
|
72
|
+
// canonical alg-confusion vector; "none" is the canonical alg-bypass.
|
|
73
|
+
var REFUSED_ALGS = ["HS256", "HS384", "HS512", "none"];
|
|
74
|
+
|
|
75
|
+
// PSS salt lengths per RFC 7518 §3.5.
|
|
76
|
+
var PSS_SALT_SHA256 = 32; // allow:raw-byte-literal — RFC 7518 SHA-256 salt length
|
|
77
|
+
var PSS_SALT_SHA384 = 48; // allow:raw-byte-literal — RFC 7518 SHA-384 salt length
|
|
78
|
+
var PSS_SALT_SHA512 = 64; // allow:raw-byte-literal — RFC 7518 SHA-512 salt length
|
|
79
|
+
|
|
80
|
+
var SUPPORTED_CLASSICAL_ALGS = [
|
|
81
|
+
"RS256", "RS384", "RS512",
|
|
82
|
+
"PS256", "PS384", "PS512",
|
|
83
|
+
"ES256", "ES384", "ES512",
|
|
84
|
+
"EdDSA",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ---- per-instance JWKS cache shared across calls ----
|
|
88
|
+
|
|
89
|
+
var _sharedJwksCache = null;
|
|
90
|
+
function _getJwksCache() {
|
|
91
|
+
if (_sharedJwksCache) return _sharedJwksCache;
|
|
92
|
+
_sharedJwksCache = cache().create({
|
|
93
|
+
namespace: "auth-jwt-external.jwks",
|
|
94
|
+
ttlMs: DEFAULT_JWKS_CACHE_MS,
|
|
95
|
+
});
|
|
96
|
+
return _sharedJwksCache;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- helpers ----
|
|
100
|
+
|
|
101
|
+
function _b64urlDecode(s) {
|
|
102
|
+
if (typeof s !== "string") {
|
|
103
|
+
throw new AuthError("auth-jwt-external/bad-base64", "expected base64url string");
|
|
104
|
+
}
|
|
105
|
+
var padded = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
106
|
+
while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
|
|
107
|
+
return Buffer.from(padded, "base64");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _verifyParamsForAlg(alg) {
|
|
111
|
+
if (alg === "RS256") return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
|
|
112
|
+
if (alg === "RS384") return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
|
|
113
|
+
if (alg === "RS512") return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
|
|
114
|
+
if (alg === "PS256") return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: PSS_SALT_SHA256 };
|
|
115
|
+
if (alg === "PS384") return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: PSS_SALT_SHA384 };
|
|
116
|
+
if (alg === "PS512") return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: PSS_SALT_SHA512 };
|
|
117
|
+
if (alg === "ES256") return { hash: "sha256", dsaEncoding: "ieee-p1363" };
|
|
118
|
+
if (alg === "ES384") return { hash: "sha384", dsaEncoding: "ieee-p1363" };
|
|
119
|
+
if (alg === "ES512") return { hash: "sha512", dsaEncoding: "ieee-p1363" };
|
|
120
|
+
if (alg === "EdDSA") return { hash: null };
|
|
121
|
+
throw new AuthError("auth-jwt-external/unsupported-alg",
|
|
122
|
+
"alg '" + alg + "' is not supported by verifyExternal");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _jwkToKey(jwk) {
|
|
126
|
+
try { return nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }
|
|
127
|
+
catch (e) {
|
|
128
|
+
throw new AuthError("auth-jwt-external/bad-jwk",
|
|
129
|
+
"could not import JWK (kid=" + (jwk && jwk.kid) + "): " + ((e && e.message) || String(e)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _toKey(value) {
|
|
134
|
+
if (!value) {
|
|
135
|
+
throw new AuthError("auth-jwt-external/no-key",
|
|
136
|
+
"key resolution returned no value");
|
|
137
|
+
}
|
|
138
|
+
if (value instanceof nodeCrypto.KeyObject) return value;
|
|
139
|
+
if (typeof value === "object" && value.kty) return _jwkToKey(value);
|
|
140
|
+
if (typeof value === "string") {
|
|
141
|
+
try { return nodeCrypto.createPublicKey({ key: value, format: "pem" }); }
|
|
142
|
+
catch (e) {
|
|
143
|
+
throw new AuthError("auth-jwt-external/bad-pem",
|
|
144
|
+
"PEM parse failed: " + ((e && e.message) || String(e)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (Buffer.isBuffer(value)) {
|
|
148
|
+
try { return nodeCrypto.createPublicKey({ key: value, format: "pem" }); }
|
|
149
|
+
catch (e) {
|
|
150
|
+
throw new AuthError("auth-jwt-external/bad-pem",
|
|
151
|
+
"PEM parse failed: " + ((e && e.message) || String(e)));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw new AuthError("auth-jwt-external/bad-key-shape",
|
|
155
|
+
"key must be a JWK object, PEM string/Buffer, or KeyObject");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function _fetchJwks(uri, cacheMs) {
|
|
159
|
+
// Validate the URI and fetch via http-client (SSRF gate, response-
|
|
160
|
+
// size cap, TLS-required by default).
|
|
161
|
+
safeUrl.parse(uri, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
162
|
+
var jc = _getJwksCache();
|
|
163
|
+
var key = "jwks:" + uri;
|
|
164
|
+
return await jc.wrap(key, async function () {
|
|
165
|
+
var res = await httpClient().request({
|
|
166
|
+
method: "GET",
|
|
167
|
+
url: uri,
|
|
168
|
+
maxBytes: MAX_JWKS_BYTES,
|
|
169
|
+
timeoutMs: C.TIME.seconds(10),
|
|
170
|
+
});
|
|
171
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { // allow:raw-byte-literal — HTTP 2xx range
|
|
172
|
+
throw new AuthError("auth-jwt-external/jwks-fetch-failed",
|
|
173
|
+
"JWKS endpoint " + uri + " returned " + res.statusCode);
|
|
174
|
+
}
|
|
175
|
+
var jwks;
|
|
176
|
+
try { jwks = safeJson.parse(res.body.toString("utf8"), { maxBytes: MAX_JWKS_BYTES }); }
|
|
177
|
+
catch (e) {
|
|
178
|
+
throw new AuthError("auth-jwt-external/jwks-parse-failed",
|
|
179
|
+
"JWKS parse failed: " + ((e && e.message) || String(e)));
|
|
180
|
+
}
|
|
181
|
+
if (!jwks || !Array.isArray(jwks.keys)) {
|
|
182
|
+
throw new AuthError("auth-jwt-external/bad-jwks",
|
|
183
|
+
"JWKS response missing 'keys' array");
|
|
184
|
+
}
|
|
185
|
+
return jwks.keys;
|
|
186
|
+
}, cacheMs || DEFAULT_JWKS_CACHE_MS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _selectKey(keys, header) {
|
|
190
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
191
|
+
throw new AuthError("auth-jwt-external/no-jwks-keys",
|
|
192
|
+
"JWKS source has no keys");
|
|
193
|
+
}
|
|
194
|
+
if (header.kid) {
|
|
195
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
196
|
+
if (keys[i].kid === header.kid) return keys[i];
|
|
197
|
+
}
|
|
198
|
+
throw new AuthError("auth-jwt-external/no-matching-kid",
|
|
199
|
+
"no JWKS key matches header.kid='" + header.kid + "'");
|
|
200
|
+
}
|
|
201
|
+
if (keys.length === 1) return keys[0];
|
|
202
|
+
throw new AuthError("auth-jwt-external/kid-required",
|
|
203
|
+
"JWKS has " + keys.length + " keys but token header has no kid");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- public surface ----
|
|
207
|
+
|
|
208
|
+
async function verifyExternal(token, opts) {
|
|
209
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
210
|
+
throw new AuthError("auth-jwt-external/no-token", "token must be a non-empty string");
|
|
211
|
+
}
|
|
212
|
+
if (token.length > MAX_TOKEN_BYTES) {
|
|
213
|
+
throw new AuthError("auth-jwt-external/token-too-large",
|
|
214
|
+
"token exceeds " + MAX_TOKEN_BYTES + " bytes");
|
|
215
|
+
}
|
|
216
|
+
opts = opts || {};
|
|
217
|
+
validateOpts(opts, [
|
|
218
|
+
"algorithms", "jwks", "jwksUri", "jwksCacheMs", "keyResolver",
|
|
219
|
+
"audience", "issuer", "subject", "clockSkewMs",
|
|
220
|
+
], "auth.jwt.verifyExternal");
|
|
221
|
+
|
|
222
|
+
if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
|
|
223
|
+
throw new AuthError("auth-jwt-external/algorithms-required",
|
|
224
|
+
"verifyExternal: opts.algorithms is required (no defaults — operator MUST " +
|
|
225
|
+
"name accepted algorithms to defend against alg-confusion)");
|
|
226
|
+
}
|
|
227
|
+
for (var ai = 0; ai < opts.algorithms.length; ai += 1) {
|
|
228
|
+
var listed = opts.algorithms[ai];
|
|
229
|
+
if (REFUSED_ALGS.indexOf(listed) !== -1) {
|
|
230
|
+
throw new AuthError("auth-jwt-external/refused-alg",
|
|
231
|
+
"verifyExternal refuses '" + listed + "' (HMAC/none is the alg-confusion vector " +
|
|
232
|
+
"against JWKS-shaped public-key trust sources)");
|
|
233
|
+
}
|
|
234
|
+
if (SUPPORTED_CLASSICAL_ALGS.indexOf(listed) === -1) {
|
|
235
|
+
throw new AuthError("auth-jwt-external/unsupported-alg",
|
|
236
|
+
"alg '" + listed + "' is not supported (supported: " +
|
|
237
|
+
SUPPORTED_CLASSICAL_ALGS.join(", ") + ")");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
var sourcesGiven = (opts.jwks ? 1 : 0) + (opts.jwksUri ? 1 : 0) +
|
|
241
|
+
(typeof opts.keyResolver === "function" ? 1 : 0);
|
|
242
|
+
if (sourcesGiven === 0) {
|
|
243
|
+
throw new AuthError("auth-jwt-external/no-key-source",
|
|
244
|
+
"verifyExternal: pass exactly one of jwks, jwksUri, keyResolver");
|
|
245
|
+
}
|
|
246
|
+
if (sourcesGiven > 1) {
|
|
247
|
+
throw new AuthError("auth-jwt-external/conflicting-key-source",
|
|
248
|
+
"verifyExternal: pass exactly one of jwks, jwksUri, keyResolver");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Decode header + payload.
|
|
252
|
+
var parts = token.split(".");
|
|
253
|
+
if (parts.length !== 3) {
|
|
254
|
+
throw new AuthError("auth-jwt-external/malformed-jwt",
|
|
255
|
+
"token does not have 3 parts");
|
|
256
|
+
}
|
|
257
|
+
var header, payload;
|
|
258
|
+
try {
|
|
259
|
+
header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: MAX_JWKS_BYTES });
|
|
260
|
+
payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: MAX_JWKS_BYTES });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
throw new AuthError("auth-jwt-external/malformed-jwt",
|
|
263
|
+
"header/payload decode failed: " + ((e && e.message) || String(e)));
|
|
264
|
+
}
|
|
265
|
+
if (!header || typeof header.alg !== "string") {
|
|
266
|
+
throw new AuthError("auth-jwt-external/malformed-jwt", "header missing 'alg'");
|
|
267
|
+
}
|
|
268
|
+
if (header.crit !== undefined) {
|
|
269
|
+
throw new AuthError("auth-jwt-external/unknown-crit",
|
|
270
|
+
"token declares 'crit' header — verifyExternal does not support critical extensions");
|
|
271
|
+
}
|
|
272
|
+
if (opts.algorithms.indexOf(header.alg) === -1) {
|
|
273
|
+
throw new AuthError("auth-jwt-external/alg-not-allowed",
|
|
274
|
+
"token alg='" + header.alg + "' not in allowed list [" + opts.algorithms.join(", ") + "]");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Resolve key.
|
|
278
|
+
var key;
|
|
279
|
+
if (typeof opts.keyResolver === "function") {
|
|
280
|
+
var resolved;
|
|
281
|
+
try { resolved = await opts.keyResolver(header); }
|
|
282
|
+
catch (e) {
|
|
283
|
+
throw new AuthError("auth-jwt-external/key-resolver-failed",
|
|
284
|
+
"keyResolver threw: " + ((e && e.message) || String(e)));
|
|
285
|
+
}
|
|
286
|
+
key = _toKey(resolved);
|
|
287
|
+
} else {
|
|
288
|
+
var keys = opts.jwks ? opts.jwks
|
|
289
|
+
: await _fetchJwks(opts.jwksUri, opts.jwksCacheMs);
|
|
290
|
+
var jwk = _selectKey(keys, header);
|
|
291
|
+
key = _jwkToKey(jwk);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Verify signature.
|
|
295
|
+
var params = _verifyParamsForAlg(header.alg);
|
|
296
|
+
var signingInput = parts[0] + "." + parts[1];
|
|
297
|
+
var sig = _b64urlDecode(parts[2]);
|
|
298
|
+
var verifyOpts = { key: key };
|
|
299
|
+
if (params.padding !== undefined) verifyOpts.padding = params.padding;
|
|
300
|
+
if (params.saltLength !== undefined) verifyOpts.saltLength = params.saltLength;
|
|
301
|
+
if (params.dsaEncoding !== undefined) verifyOpts.dsaEncoding = params.dsaEncoding;
|
|
302
|
+
var verified;
|
|
303
|
+
try {
|
|
304
|
+
verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
throw new AuthError("auth-jwt-external/invalid-signature",
|
|
307
|
+
"signature verification failed: " + ((e && e.message) || String(e)));
|
|
308
|
+
}
|
|
309
|
+
if (!verified) {
|
|
310
|
+
throw new AuthError("auth-jwt-external/invalid-signature",
|
|
311
|
+
"signature verification failed");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Claim validation.
|
|
315
|
+
var clockSkewMs = typeof opts.clockSkewMs === "number" ? opts.clockSkewMs : DEFAULT_CLOCK_SKEW_MS;
|
|
316
|
+
var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
317
|
+
var skewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
|
|
318
|
+
|
|
319
|
+
if (typeof payload.exp !== "number") {
|
|
320
|
+
throw new AuthError("auth-jwt-external/missing-exp", "claim 'exp' missing");
|
|
321
|
+
}
|
|
322
|
+
if (payload.exp + skewSec < nowSec) {
|
|
323
|
+
throw new AuthError("auth-jwt-external/expired",
|
|
324
|
+
"token expired (exp=" + payload.exp + ", now=" + nowSec + ")");
|
|
325
|
+
}
|
|
326
|
+
if (typeof payload.nbf === "number" && payload.nbf - skewSec > nowSec) {
|
|
327
|
+
throw new AuthError("auth-jwt-external/nbf-future",
|
|
328
|
+
"token not-yet-valid (nbf=" + payload.nbf + ", now=" + nowSec + ")");
|
|
329
|
+
}
|
|
330
|
+
if (typeof payload.iat === "number" && payload.iat - skewSec > nowSec) {
|
|
331
|
+
throw new AuthError("auth-jwt-external/iat-future",
|
|
332
|
+
"token iat is in the future");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (opts.audience) {
|
|
336
|
+
var aud = payload.aud;
|
|
337
|
+
var expectedAud = Array.isArray(opts.audience) ? opts.audience : [opts.audience];
|
|
338
|
+
var actualAud = Array.isArray(aud) ? aud : (typeof aud === "string" ? [aud] : []);
|
|
339
|
+
var audMatch = false;
|
|
340
|
+
for (var ej = 0; ej < expectedAud.length; ej += 1) {
|
|
341
|
+
if (actualAud.indexOf(expectedAud[ej]) !== -1) { audMatch = true; break; }
|
|
342
|
+
}
|
|
343
|
+
if (!audMatch) {
|
|
344
|
+
throw new AuthError("auth-jwt-external/aud-mismatch",
|
|
345
|
+
"token aud '" + JSON.stringify(aud) + "' does not match expected '" +
|
|
346
|
+
JSON.stringify(opts.audience) + "'");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (opts.issuer && payload.iss !== opts.issuer) {
|
|
350
|
+
throw new AuthError("auth-jwt-external/iss-mismatch",
|
|
351
|
+
"token iss '" + payload.iss + "' does not match expected '" + opts.issuer + "'");
|
|
352
|
+
}
|
|
353
|
+
if (opts.subject && payload.sub !== opts.subject) {
|
|
354
|
+
throw new AuthError("auth-jwt-external/sub-mismatch",
|
|
355
|
+
"token sub '" + payload.sub + "' does not match expected '" + opts.subject + "'");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { header: header, claims: payload };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
verifyExternal: verifyExternal,
|
|
363
|
+
SUPPORTED_CLASSICAL_ALGS: SUPPORTED_CLASSICAL_ALGS,
|
|
364
|
+
REFUSED_ALGS: REFUSED_ALGS,
|
|
365
|
+
};
|
package/lib/auth/jwt.js
CHANGED
|
@@ -212,9 +212,35 @@ async function verify(token, opts) {
|
|
|
212
212
|
SUPPORTED_ALGORITHMS.join(", ") + ")");
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
-
var key = _toKeyObject(opts.publicKey, "public");
|
|
216
215
|
var decoded = decode(token);
|
|
217
216
|
|
|
217
|
+
// keyResolver — operator-supplied per-token key lookup (typically by
|
|
218
|
+
// header.kid against a rotation table or remote JWKS). Mutually
|
|
219
|
+
// exclusive with opts.publicKey: pass one or the other, not both.
|
|
220
|
+
// The resolver receives the FULL decoded header and returns either
|
|
221
|
+
// the key (sync) or a Promise<key> (async).
|
|
222
|
+
var key;
|
|
223
|
+
if (typeof opts.keyResolver === "function") {
|
|
224
|
+
if (opts.publicKey !== undefined) {
|
|
225
|
+
throw new AuthError("auth-jwt/conflicting-key-source",
|
|
226
|
+
"verify: pass keyResolver OR publicKey, not both");
|
|
227
|
+
}
|
|
228
|
+
var resolved;
|
|
229
|
+
try { resolved = await opts.keyResolver(decoded.header); }
|
|
230
|
+
catch (e) {
|
|
231
|
+
throw new AuthError("auth-jwt/key-resolver-failed",
|
|
232
|
+
"keyResolver threw: " + ((e && e.message) || String(e)));
|
|
233
|
+
}
|
|
234
|
+
if (!resolved) {
|
|
235
|
+
throw new AuthError("auth-jwt/key-not-found",
|
|
236
|
+
"keyResolver returned no key for kid='" +
|
|
237
|
+
(decoded.header.kid || "<absent>") + "'");
|
|
238
|
+
}
|
|
239
|
+
key = _toKeyObject(resolved, "public");
|
|
240
|
+
} else {
|
|
241
|
+
key = _toKeyObject(opts.publicKey, "public");
|
|
242
|
+
}
|
|
243
|
+
|
|
218
244
|
// Reject unknown critical-header extensions outright (RFC 7515 §4.1.11)
|
|
219
245
|
if (decoded.header.crit !== undefined) {
|
|
220
246
|
throw new AuthError("auth-jwt/unknown-crit",
|