@blamejs/core 0.7.19 → 0.7.21
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 +4 -0
- package/index.js +16 -0
- package/lib/audit-sign.js +17 -10
- package/lib/cookies.js +35 -0
- package/lib/middleware/csrf-protect.js +84 -1
- package/lib/middleware/fetch-metadata.js +129 -0
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/security-headers.js +12 -7
- package/lib/pick.js +105 -0
- package/lib/safe-redirect.js +106 -0
- package/lib/static.js +7 -5
- package/lib/webhook.js +27 -7
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.21** (2026-05-05) — small primitive batch (5 fixes): TLS 1.3 framework-wide minimum, `b.safeRedirect`, `b.pick`, audit-sign legacy compat-shim removed, webhook PQC signatures emit base64url. **Framework-wide TLS 1.3 minimum** — `index.js` sets `tls.DEFAULT_MIN_VERSION = "TLSv1.3"` once at boot, before any framework module loads `node:tls`. Applies to every TLS socket (outbound `https.request` / mail SMTP+STARTTLS / Redis-Postgres-Mongo TLS / `b.httpClient`, AND inbound `https.createServer` when blamejs is the listener). Per-call override still works for legacy peers. **`b.safeRedirect`** — open-redirect (CWE-601) defense. `b.safeRedirect.resolve(rawTarget, { allowedOrigins, allowedHosts, fallback })` returns the safe URL (or fallback). Refuses protocol-relative (`//attacker.com`), backslash variants (`\\\\attacker.com`), control-char-laden (CRLF header injection), and `data:` / `javascript:` schemes; same-origin paths (`/dashboard`) and fragments (`#x`) pass through; full URLs require explicit allowlist. Operator drops the result straight into `res.writeHead(302, { Location: ... })`. **`b.pick`** — mass-assignment (CWE-915 / OWASP API3:2023) defense. `b.pick(req.body, ["a", "b", ["nested", ["sub1"]]])` returns a NEW object with only allowlisted keys; prototype-pollution keys (`__proto__` / `constructor` / `prototype`) ALWAYS stripped even if listed. `opts.onUnknown: "throw"` rejects unknown keys instead of silently dropping. Nested allowlist syntax for object-shaped fields. **Audit-sign legacy compat-shim removed** — `lib/audit-sign.js` no longer falls back to `ml-dsa-87` for key files missing the `algorithm` field. Throws `KEY_FILE_MISSING_ALG` / `UNWRAPPED_MISSING_ALG` at load time; operators with legacy files rotate the key (deletes + regenerates) or hand-edit to add `"algorithm": "slh-dsa-shake-256f"`. Pre-v1 compat-shim sweep per the no-pre-v1-compat rule. **Webhook PQC signatures emit base64url** — `b.webhook` now signs to base64url (was hex). SLH-DSA-SHAKE-256f signatures are ~29.5 KB binary → ~40 KB base64url vs ~59 KB hex; the hex form blew past nginx default 8 KB / Cloudflare default 16 KB / many CDN edge limits. Verification accepts EITHER encoding for a transition window — base64url-shaped sig values decode as base64url; hex-shaped values decode as hex. Smoke 8478 / wiki e2e 178 / Linux container smoke 8478 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
12
|
+
|
|
13
|
+
- **0.7.20** (2026-05-05) — browser-hardening batch (5 fixes): CSRF Origin/Referer cross-check, default CSP gains Trusted Types, expanded Permissions-Policy, `__Host-` / `__Secure-` cookie prefix invariants, `b.middleware.fetchMetadata`. **CSRF Origin/Referer cross-check** (`b.middleware.csrfProtect`) — second-line defense alongside the double-submit token. State-changing requests whose Origin (or Referer when Origin is absent) doesn't resolve to the request's own origin are refused before the token check. Defaults ON; operator opt-out via `checkOrigin: false`; operator allowlist via `allowedOrigins: ["https://app.example.com"]`. Missing-Origin-AND-Missing-Referer (curl, server-to-server) defers to the token check. **Trusted Types in default CSP** (`b.middleware.securityHeaders`) — default CSP now includes `require-trusted-types-for 'script'; trusted-types 'allow-duplicates' default;`. Compatible browsers (Chrome 83+, Edge 83+) enforce typed-value DOM-sink writes (defends every untrusted-string-to-DOM XSS vector at runtime); Firefox + Safari ignore (no regression). Operators opt out by passing an explicit `csp:` value. **Expanded Permissions-Policy** — defaults now disable `browsing-topics=()`, `attribution-reporting=()`, `unload=()`, `interest-cohort=()`, `join-ad-interest-group=()`, `run-ad-auction=()`, `private-state-token-issuance=()`, `private-state-token-redemption=()`, `compute-pressure=()`, `hid=()`, `serial=()`, `idle-detection=()` — closes advertising / tracking / load-event-leak / device-API surfaces. Existing operator overrides via `permissionsPolicy: "..."` continue unchanged. **`__Host-` / `__Secure-` cookie prefix invariants** (`b.cookies.serialize`) — RFC 6265bis §4.1.3 enforced at serialize time. `__Secure-*` requires `secure: true`; `__Host-*` requires `secure: true` AND `path: "/"` AND no `domain:`. Each violation throws a typed `CookieError` with operator-actionable code (`cookies/prefix-secure-required`, `cookies/prefix-host-secure-required`, `cookies/prefix-host-path-required`, `cookies/prefix-host-no-domain`) instead of producing a malformed cookie that browsers silently reject. **`b.middleware.fetchMetadata`** — new fetch-metadata isolation primitive. Reads `Sec-Fetch-Site` / `Sec-Fetch-Mode` / `Sec-Fetch-Dest` and refuses cross-site state-changing requests by default. Operators opt in to specific destinations (e.g. `allowedDest: ["empty", "document"]`) or specific origins (`allowCrossSite: true`). Direct navigations (typed URL / bookmark — `Sec-Fetch-Site: none`) pass through; `same-origin` always passes; `same-site` configurable. Missing fetch-metadata (legacy browsers, server-to-server) deferred to other auth/CSRF layers per `allowMissing: true` default. Smoke 8481 / wiki e2e 178 / Linux container smoke 8481 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
|
|
14
|
+
|
|
11
15
|
- **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
16
|
|
|
13
17
|
- **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.
|
package/index.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
|
|
3
|
+
// TLS 1.3 minimum, framework-wide. Sets the default for every TLS
|
|
4
|
+
// socket the process opens — outbound (https.request, mail SMTP+
|
|
5
|
+
// STARTTLS, redis/postgres/mongo with TLS, http-client) AND inbound
|
|
6
|
+
// (https.createServer when blamejs is the listener). Per-call override
|
|
7
|
+
// still works when an operator with a legacy peer needs TLSv1.2.
|
|
8
|
+
// node:tls reads `DEFAULT_MIN_VERSION` once at first TLS use; setting
|
|
9
|
+
// it here, before any framework module loads node:tls, makes the
|
|
10
|
+
// default sticky for the entire process.
|
|
11
|
+
var _tls = require("node:tls");
|
|
12
|
+
_tls.DEFAULT_MIN_VERSION = "TLSv1.3";
|
|
13
|
+
|
|
2
14
|
/**
|
|
3
15
|
* blamejs — public API entry point.
|
|
4
16
|
*
|
|
@@ -83,6 +95,8 @@ httpClient.encrypted = require("./lib/middleware/api-encrypt").httpClient;
|
|
|
83
95
|
httpClient.cookieJar = require("./lib/http-client-cookie-jar");
|
|
84
96
|
var websocket = require("./lib/websocket");
|
|
85
97
|
var safeUrl = require("./lib/safe-url");
|
|
98
|
+
var safeRedirect = require("./lib/safe-redirect");
|
|
99
|
+
var pick = require("./lib/pick");
|
|
86
100
|
var gateContract = require("./lib/gate-contract");
|
|
87
101
|
var guardCsv = require("./lib/guard-csv");
|
|
88
102
|
var guardHtml = require("./lib/guard-html");
|
|
@@ -220,6 +234,8 @@ module.exports = {
|
|
|
220
234
|
httpClient: httpClient,
|
|
221
235
|
websocket: websocket,
|
|
222
236
|
safeUrl: safeUrl,
|
|
237
|
+
safeRedirect: safeRedirect,
|
|
238
|
+
pick: pick,
|
|
223
239
|
gateContract: gateContract,
|
|
224
240
|
guardCsv: guardCsv,
|
|
225
241
|
guardHtml: guardHtml,
|
package/lib/audit-sign.js
CHANGED
|
@@ -77,12 +77,11 @@ var _err = AuditSignError.factory;
|
|
|
77
77
|
|
|
78
78
|
// Default for newly-generated keys. Operators can override at init
|
|
79
79
|
// via opts.algorithm — e.g. `auditSigning: { algorithm: "ml-dsa-87" }`
|
|
80
|
-
// for throughput-sensitive deployments.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
80
|
+
// for throughput-sensitive deployments. Every key file MUST carry the
|
|
81
|
+
// `algorithm` field on disk — the framework refuses to load a key file
|
|
82
|
+
// that lacks it. The legacy implicit-default-to-ml-dsa-87 fallback was
|
|
83
|
+
// removed as part of the pre-v1 compat-shim sweep.
|
|
84
84
|
var DEFAULT_SIGNING_ALG = "slh-dsa-shake-256f";
|
|
85
|
-
var LEGACY_DEFAULT_ALG = "ml-dsa-87";
|
|
86
85
|
var SUPPORTED_SIGNING_ALGS = Object.freeze(["slh-dsa-shake-256f", "ml-dsa-87"]);
|
|
87
86
|
|
|
88
87
|
var SIGNING_KEY_SCHEMA = {
|
|
@@ -202,11 +201,16 @@ function _initPlaintext() {
|
|
|
202
201
|
throw _err("KEY_FILE_CORRUPT",
|
|
203
202
|
"audit-sign.key corrupted or schema-invalid at " + paths.plaintext + " - " + e.message);
|
|
204
203
|
}
|
|
205
|
-
|
|
204
|
+
if (typeof loaded.algorithm !== "string" || loaded.algorithm.length === 0) {
|
|
205
|
+
throw _err("KEY_FILE_MISSING_ALG",
|
|
206
|
+
"audit-sign.key at " + paths.plaintext + " is missing the required " +
|
|
207
|
+
"`algorithm` field. Regenerate the keypair (deletes the file and " +
|
|
208
|
+
"boots fresh) or hand-edit to add `\"algorithm\": \"slh-dsa-shake-256f\"`.");
|
|
209
|
+
}
|
|
206
210
|
keys = {
|
|
207
211
|
publicKey: loaded.publicKey,
|
|
208
212
|
privateKey: loaded.privateKey,
|
|
209
|
-
algorithm:
|
|
213
|
+
algorithm: loaded.algorithm,
|
|
210
214
|
fingerprint: _computeFingerprint(loaded.publicKey),
|
|
211
215
|
};
|
|
212
216
|
return;
|
|
@@ -248,14 +252,17 @@ async function _initWrapped() {
|
|
|
248
252
|
throw _err("UNWRAPPED_INVALID",
|
|
249
253
|
"unwrapped audit-sign.key invalid: " + e.message);
|
|
250
254
|
}
|
|
251
|
-
|
|
255
|
+
if (typeof loaded.algorithm !== "string" || loaded.algorithm.length === 0) {
|
|
256
|
+
throw _err("UNWRAPPED_MISSING_ALG",
|
|
257
|
+
"unwrapped audit-sign.key is missing the required `algorithm` field.");
|
|
258
|
+
}
|
|
252
259
|
keys = {
|
|
253
260
|
publicKey: loaded.publicKey,
|
|
254
261
|
privateKey: loaded.privateKey,
|
|
255
|
-
algorithm:
|
|
262
|
+
algorithm: loaded.algorithm,
|
|
256
263
|
fingerprint: _computeFingerprint(loaded.publicKey),
|
|
257
264
|
};
|
|
258
|
-
log("audit-signing keypair unsealed (alg=" +
|
|
265
|
+
log("audit-signing keypair unsealed (alg=" + loaded.algorithm + ").");
|
|
259
266
|
} finally {
|
|
260
267
|
// The audit-signing passphrase is single-use at boot — no re-wrap path
|
|
261
268
|
// keeps it alive (unlike vault.currentPassphrase). Zero on the way out.
|
package/lib/cookies.js
CHANGED
|
@@ -144,6 +144,41 @@ function serialize(name, value, attrs) {
|
|
|
144
144
|
_validateValue(value);
|
|
145
145
|
attrs = attrs || {};
|
|
146
146
|
|
|
147
|
+
// RFC 6265bis §4.1.3 cookie-prefix invariants — refused at serialize-
|
|
148
|
+
// time so an operator-side typo doesn't ship a broken cookie that
|
|
149
|
+
// browsers silently reject (and the operator wonders why their cookie
|
|
150
|
+
// never sets).
|
|
151
|
+
//
|
|
152
|
+
// __Secure-* — MUST be Secure
|
|
153
|
+
// __Host-* — MUST be Secure, Path=/, NO Domain
|
|
154
|
+
//
|
|
155
|
+
// Caught at the source so every caller (csrf-protect / session /
|
|
156
|
+
// operator) gets the same enforcement.
|
|
157
|
+
if (name.indexOf("__Secure-") === 0) {
|
|
158
|
+
if (attrs.secure !== true) {
|
|
159
|
+
throw new CookieError("cookies/prefix-secure-required",
|
|
160
|
+
"__Secure-* cookies MUST set Secure (RFC 6265bis §4.1.3.1) — got '" +
|
|
161
|
+
name + "' without secure: true");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (name.indexOf("__Host-") === 0) {
|
|
165
|
+
if (attrs.secure !== true) {
|
|
166
|
+
throw new CookieError("cookies/prefix-host-secure-required",
|
|
167
|
+
"__Host-* cookies MUST set Secure (RFC 6265bis §4.1.3.2) — got '" +
|
|
168
|
+
name + "' without secure: true");
|
|
169
|
+
}
|
|
170
|
+
if (attrs.path !== "/") {
|
|
171
|
+
throw new CookieError("cookies/prefix-host-path-required",
|
|
172
|
+
"__Host-* cookies MUST set Path=/ (RFC 6265bis §4.1.3.2) — got '" +
|
|
173
|
+
name + "' with path=" + JSON.stringify(attrs.path || "<unset>"));
|
|
174
|
+
}
|
|
175
|
+
if (attrs.domain) {
|
|
176
|
+
throw new CookieError("cookies/prefix-host-no-domain",
|
|
177
|
+
"__Host-* cookies MUST NOT set Domain (RFC 6265bis §4.1.3.2) — got '" +
|
|
178
|
+
name + "' with domain=" + JSON.stringify(attrs.domain));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
147
182
|
var parts = [name + "=" + encodeURIComponent(value)];
|
|
148
183
|
|
|
149
184
|
if (attrs.maxAge !== undefined && attrs.maxAge !== null) {
|
|
@@ -155,6 +155,63 @@ function _appendSetCookie(res, value) {
|
|
|
155
155
|
// owns size caps, content-type dispatch, and prototype-pollution
|
|
156
156
|
// defense; csrf-protect just reads the validated value.
|
|
157
157
|
|
|
158
|
+
// Origin / Referer cross-check helper. Returns null when the request's
|
|
159
|
+
// origin is acceptable, or a short reason string ("origin-mismatch" /
|
|
160
|
+
// "referer-mismatch" / "no-origin-or-referer" / "malformed-url") when
|
|
161
|
+
// it should be refused. Compares against opts.allowedOrigins (when
|
|
162
|
+
// supplied) OR same-origin (the request's own Host header).
|
|
163
|
+
//
|
|
164
|
+
// Implementation note — we deliberately don't run safeUrl here; safeUrl
|
|
165
|
+
// throws on file:// / data:// schemes which would crash the middleware
|
|
166
|
+
// instead of refusing the request. URL constructor + try/catch is the
|
|
167
|
+
// right shape for "is this URL well-formed and what's its origin?".
|
|
168
|
+
function _checkOriginAllowed(req, allowedOrigins, isHttpsFn) {
|
|
169
|
+
var headers = req.headers || {};
|
|
170
|
+
var origin = headers.origin;
|
|
171
|
+
var referer = headers.referer;
|
|
172
|
+
if (typeof origin !== "string" && typeof referer !== "string") {
|
|
173
|
+
// No Origin/Referer at all — common for non-browser clients
|
|
174
|
+
// (curl, server-to-server). The token check still applies; this
|
|
175
|
+
// gate doesn't add to it. Defense-in-depth against a stolen
|
|
176
|
+
// cookie via a browser-rendered cross-origin fetch IS the value;
|
|
177
|
+
// headless clients carry their own auth threat model.
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
var requestOrigin = (isHttpsFn && isHttpsFn(req) ? "https://" : "http://") +
|
|
182
|
+
(headers.host || "");
|
|
183
|
+
|
|
184
|
+
function _originOf(rawUrl) {
|
|
185
|
+
try {
|
|
186
|
+
var u = new URL(rawUrl); // allow:raw-new-url — origin-shape inspection (NOT outbound). Intentionally tolerates file:// / data: which safeUrl.parse refuses.
|
|
187
|
+
return u.origin; // "https://host:port" — no path / query / fragment
|
|
188
|
+
} catch (_e) { return null; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _isAllowed(candidateOrigin) {
|
|
192
|
+
if (!candidateOrigin) return false;
|
|
193
|
+
if (candidateOrigin === requestOrigin) return true;
|
|
194
|
+
if (Array.isArray(allowedOrigins)) {
|
|
195
|
+
for (var i = 0; i < allowedOrigins.length; i += 1) {
|
|
196
|
+
if (candidateOrigin === allowedOrigins[i]) return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof origin === "string" && origin.length > 0) {
|
|
203
|
+
var oo = _originOf(origin);
|
|
204
|
+
if (oo === null) return "malformed-origin";
|
|
205
|
+
if (!_isAllowed(oo)) return "origin-mismatch (" + oo + " vs " + requestOrigin + ")";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// Origin absent — fall back to Referer.
|
|
209
|
+
var ro = _originOf(referer);
|
|
210
|
+
if (ro === null) return "malformed-referer";
|
|
211
|
+
if (!_isAllowed(ro)) return "referer-mismatch (" + ro + " vs " + requestOrigin + ")";
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
158
215
|
function _writeReject(res, message) {
|
|
159
216
|
if (typeof res.writeHead === "function") {
|
|
160
217
|
var body = JSON.stringify({ error: message });
|
|
@@ -171,7 +228,7 @@ function create(opts) {
|
|
|
171
228
|
|
|
172
229
|
validateOpts(opts, [
|
|
173
230
|
"cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
|
|
174
|
-
"trustProxy",
|
|
231
|
+
"trustProxy", "checkOrigin", "allowedOrigins",
|
|
175
232
|
], "middleware.csrfProtect");
|
|
176
233
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
177
234
|
? opts.trustProxy : false;
|
|
@@ -193,6 +250,20 @@ function create(opts) {
|
|
|
193
250
|
var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
|
|
194
251
|
var auditOn = opts.audit !== false;
|
|
195
252
|
|
|
253
|
+
// Origin / Referer cross-check — second-line defense alongside the
|
|
254
|
+
// double-submit token. If the request's Origin (or Referer when
|
|
255
|
+
// Origin is absent — Safari pre-12, certain CORS mode requests)
|
|
256
|
+
// doesn't resolve to a same-origin or operator-allowlisted origin,
|
|
257
|
+
// refuse before the token check.
|
|
258
|
+
//
|
|
259
|
+
// Default: enabled (defense-in-depth — same shape as bot-guard /
|
|
260
|
+
// rate-limit / CSP nonce — every default ON per Core Rule §3).
|
|
261
|
+
// Operator opt-out: opts.checkOrigin = false.
|
|
262
|
+
// Operator allowlist: opts.allowedOrigins = ["https://app.example.com"].
|
|
263
|
+
var checkOrigin = opts.checkOrigin !== false;
|
|
264
|
+
var allowedOrigins = Array.isArray(opts.allowedOrigins)
|
|
265
|
+
? opts.allowedOrigins.slice() : null;
|
|
266
|
+
|
|
196
267
|
// Cookie issuance config (only when opts.cookie is set).
|
|
197
268
|
var cookieCfg = null;
|
|
198
269
|
if (hasCookie) {
|
|
@@ -282,6 +353,18 @@ function create(opts) {
|
|
|
282
353
|
|
|
283
354
|
if (methods.indexOf(req.method) === -1) return next();
|
|
284
355
|
|
|
356
|
+
// Origin / Referer cross-check (defense-in-depth alongside the
|
|
357
|
+
// double-submit token). Refuses cross-origin state-changing
|
|
358
|
+
// requests even when the token is valid (e.g. operator-mistaken
|
|
359
|
+
// CORS configuration that exposes the cookie).
|
|
360
|
+
if (checkOrigin) {
|
|
361
|
+
var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps);
|
|
362
|
+
if (originReason !== null) {
|
|
363
|
+
_emitDenied(req, "origin/referer: " + originReason);
|
|
364
|
+
return _writeReject(res, "CSRF cross-origin request refused.");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
285
368
|
if (!cookieCfg) {
|
|
286
369
|
// Session-stored mode — operator's tokenLookup is the source.
|
|
287
370
|
expected = opts.tokenLookup(req);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* fetch-metadata — Sec-Fetch-Site / Sec-Fetch-Mode / Sec-Fetch-Dest
|
|
4
|
+
* isolation primitive (Resource Isolation Policy / Site Isolation —
|
|
5
|
+
* https://web.dev/fetch-metadata/).
|
|
6
|
+
*
|
|
7
|
+
* Browsers attach Sec-Fetch-* headers describing the FETCH context
|
|
8
|
+
* (same-site? cross-site? typed-URL? navigation? script load?).
|
|
9
|
+
* This middleware refuses cross-site requests on state-changing
|
|
10
|
+
* methods unless the operator explicitly allowlists them — a second-
|
|
11
|
+
* line defense alongside CSRF tokens.
|
|
12
|
+
*
|
|
13
|
+
* var fmGate = b.middleware.fetchMetadata({
|
|
14
|
+
* allowSameSite: true, // default
|
|
15
|
+
* allowCrossSite: false, // default — refuse cross-site state changes
|
|
16
|
+
* allowedDest: ["empty", "document"], // operator allowlist of Sec-Fetch-Dest
|
|
17
|
+
* allowedNavigate: true, // allow direct navigations (typed URL / bookmark)
|
|
18
|
+
* methods: ["POST","PUT","DELETE","PATCH"],
|
|
19
|
+
* audit: true,
|
|
20
|
+
* });
|
|
21
|
+
* router.use("/api", fmGate);
|
|
22
|
+
*
|
|
23
|
+
* Refusal shape: 403 application/json + audit row. Same-origin /
|
|
24
|
+
* same-site requests pass through; cross-site refused unless
|
|
25
|
+
* allowedDest contains the request's Sec-Fetch-Dest. None / undefined
|
|
26
|
+
* (legacy browsers without fetch-metadata) is treated per
|
|
27
|
+
* `allowMissing` (default true — don't break older clients).
|
|
28
|
+
*
|
|
29
|
+
* Fail-open posture: when the request is missing Sec-Fetch-* entirely
|
|
30
|
+
* (curl, server-to-server, browser <Chrome 76 / <Firefox 90 / <Safari
|
|
31
|
+
* 16.4), the gate defers to other auth/CSRF layers. The browser-fetch-
|
|
32
|
+
* metadata isolation IS the value-add; non-browser clients carry their
|
|
33
|
+
* own auth threat model.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var requestHelpers = require("../request-helpers");
|
|
37
|
+
var validateOpts = require("../validate-opts");
|
|
38
|
+
var lazyRequire = require("../lazy-require");
|
|
39
|
+
|
|
40
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
41
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
42
|
+
|
|
43
|
+
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
44
|
+
|
|
45
|
+
function _writeReject(res, message) {
|
|
46
|
+
if (res.headersSent) return;
|
|
47
|
+
var body = JSON.stringify({ error: message });
|
|
48
|
+
res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
|
|
49
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
50
|
+
"Content-Length": Buffer.byteLength(body),
|
|
51
|
+
});
|
|
52
|
+
res.end(body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function create(opts) {
|
|
56
|
+
opts = opts || {};
|
|
57
|
+
validateOpts(opts, [
|
|
58
|
+
"allowSameSite", "allowCrossSite", "allowMissing",
|
|
59
|
+
"allowedDest", "allowedNavigate", "methods", "audit",
|
|
60
|
+
], "middleware.fetchMetadata");
|
|
61
|
+
|
|
62
|
+
var allowSameSite = opts.allowSameSite !== false;
|
|
63
|
+
var allowCrossSite = opts.allowCrossSite === true;
|
|
64
|
+
var allowMissing = opts.allowMissing !== false;
|
|
65
|
+
var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
|
|
66
|
+
var allowedNavigate = opts.allowedNavigate !== false;
|
|
67
|
+
var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
|
|
68
|
+
var auditOn = opts.audit !== false;
|
|
69
|
+
|
|
70
|
+
function _emitDenied(req, reason) {
|
|
71
|
+
if (!auditOn) return;
|
|
72
|
+
try {
|
|
73
|
+
audit().safeEmit({
|
|
74
|
+
action: "auth.fetch_metadata.denied",
|
|
75
|
+
outcome: "denied",
|
|
76
|
+
actor: requestHelpers.extractActorContext(req),
|
|
77
|
+
reason: reason,
|
|
78
|
+
metadata: { method: req.method, path: (req.url || "").split("?")[0] },
|
|
79
|
+
});
|
|
80
|
+
} catch (_e) { /* audit best-effort */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return function fetchMetadata(req, res, next) {
|
|
84
|
+
if (methods.indexOf(req.method) === -1) return next();
|
|
85
|
+
|
|
86
|
+
var headers = req.headers || {};
|
|
87
|
+
var site = headers["sec-fetch-site"];
|
|
88
|
+
var mode = headers["sec-fetch-mode"];
|
|
89
|
+
var dest = headers["sec-fetch-dest"];
|
|
90
|
+
|
|
91
|
+
if (typeof site !== "string" || site.length === 0) {
|
|
92
|
+
// No Sec-Fetch-Site header — legacy browser or non-browser client.
|
|
93
|
+
// Defer to other auth/CSRF layers per allowMissing.
|
|
94
|
+
if (!allowMissing) {
|
|
95
|
+
_emitDenied(req, "fetch-metadata-missing");
|
|
96
|
+
return _writeReject(res, "Fetch-metadata required.");
|
|
97
|
+
}
|
|
98
|
+
return next();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Direct navigations — typed URL, bookmark, history navigation.
|
|
102
|
+
if (site === "none") {
|
|
103
|
+
if (allowedNavigate) return next();
|
|
104
|
+
_emitDenied(req, "navigate-disallowed");
|
|
105
|
+
return _writeReject(res, "Direct navigation not allowed for this method.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (site === "same-origin") return next();
|
|
109
|
+
|
|
110
|
+
if (site === "same-site") {
|
|
111
|
+
if (allowSameSite) return next();
|
|
112
|
+
_emitDenied(req, "same-site-disallowed");
|
|
113
|
+
return _writeReject(res, "Same-site request not allowed.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// cross-site
|
|
117
|
+
if (allowCrossSite) return next();
|
|
118
|
+
if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
|
|
119
|
+
return next();
|
|
120
|
+
}
|
|
121
|
+
_emitDenied(req, "cross-site-refused (mode=" + (mode || "?") +
|
|
122
|
+
", dest=" + (dest || "?") + ")");
|
|
123
|
+
try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
|
|
124
|
+
catch (_e) { /* best-effort */ }
|
|
125
|
+
return _writeReject(res, "Cross-site request refused.");
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { create: create };
|
package/lib/middleware/index.js
CHANGED
|
@@ -27,6 +27,7 @@ var cspNonce = require("./csp-nonce");
|
|
|
27
27
|
var csrfProtect = require("./csrf-protect");
|
|
28
28
|
var dbRoleFor = require("./db-role-for");
|
|
29
29
|
var errorHandler = require("./error-handler");
|
|
30
|
+
var fetchMetadata = require("./fetch-metadata");
|
|
30
31
|
var health = require("./health");
|
|
31
32
|
var networkAllowlist = require("./network-allowlist");
|
|
32
33
|
var rateLimit = require("./rate-limit");
|
|
@@ -47,6 +48,7 @@ module.exports = {
|
|
|
47
48
|
bearerAuth: bearerAuth.create,
|
|
48
49
|
requireAuth: requireAuth.create,
|
|
49
50
|
csrfProtect: csrfProtect.create,
|
|
51
|
+
fetchMetadata: fetchMetadata.create,
|
|
50
52
|
bodyParser: bodyParser.create,
|
|
51
53
|
health: health.create,
|
|
52
54
|
compression: compression.create,
|
|
@@ -69,6 +71,7 @@ module.exports = {
|
|
|
69
71
|
bearerAuth: bearerAuth,
|
|
70
72
|
requireAuth: requireAuth,
|
|
71
73
|
csrfProtect: csrfProtect,
|
|
74
|
+
fetchMetadata: fetchMetadata,
|
|
72
75
|
bodyParser: bodyParser,
|
|
73
76
|
health: health,
|
|
74
77
|
compression: compression,
|
|
@@ -46,12 +46,15 @@ var DEFAULT_PERMISSIONS = [
|
|
|
46
46
|
"screen-wake-lock=()", "sync-xhr=()", "usb=()", "web-share=()", "xr-spatial-tracking=()",
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
-
// Strict CSP — no 'unsafe-inline' on script-src OR style-src.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
49
|
+
// Strict CSP — no 'unsafe-inline' on script-src OR style-src.
|
|
50
|
+
// Trusted Types (require-trusted-types-for 'script') enables the
|
|
51
|
+
// browser's strongest XSS-mitigation primitive — DOM-sink writes via
|
|
52
|
+
// innerHTML / outerHTML / setHTML require typed values, surfacing
|
|
53
|
+
// every untrusted-string-to-DOM path at runtime so operators can audit
|
|
54
|
+
// + fix them. Compatible browsers (Chrome 83+, Edge 83+) enforce;
|
|
55
|
+
// Firefox + Safari ignore (no regression). Operators with inline
|
|
56
|
+
// scripts wire `b.middleware.cspNonce()` and use `{{ cspNonce }}` in
|
|
57
|
+
// views.
|
|
55
58
|
var DEFAULT_CSP =
|
|
56
59
|
"default-src 'self'; " +
|
|
57
60
|
"script-src 'self'; " +
|
|
@@ -62,7 +65,9 @@ var DEFAULT_CSP =
|
|
|
62
65
|
"frame-ancestors 'none'; " +
|
|
63
66
|
"base-uri 'self'; " +
|
|
64
67
|
"form-action 'self'; " +
|
|
65
|
-
"object-src 'none';"
|
|
68
|
+
"object-src 'none'; " +
|
|
69
|
+
"require-trusted-types-for 'script'; " +
|
|
70
|
+
"trusted-types 'allow-duplicates' default;";
|
|
66
71
|
|
|
67
72
|
function create(opts) {
|
|
68
73
|
opts = opts || {};
|
package/lib/pick.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.pick — mass-assignment (CWE-915 / OWASP API3:2023) defense.
|
|
4
|
+
*
|
|
5
|
+
* The vulnerability: a route accepting `JSON` body and passing
|
|
6
|
+
* `req.body` straight to a DB write lets an attacker include fields
|
|
7
|
+
* the operator never intended (`isAdmin`, `passwordHash`, `userId`).
|
|
8
|
+
* This primitive is the operator's allowlist of acceptable fields —
|
|
9
|
+
* pass req.body through it before persisting.
|
|
10
|
+
*
|
|
11
|
+
* var safeUserUpdate = b.pick(req.body, [
|
|
12
|
+
* "displayName", "bio", "avatarUrl",
|
|
13
|
+
* ]);
|
|
14
|
+
* await db.users.update(userId, safeUserUpdate);
|
|
15
|
+
*
|
|
16
|
+
* Returns a NEW object containing only the keys in the allowlist.
|
|
17
|
+
* Keys not present in the input are simply absent from the output —
|
|
18
|
+
* no defaults filled in, no `undefined` values. Prototype-pollution
|
|
19
|
+
* keys (`__proto__` / `constructor` / `prototype`) are ALWAYS
|
|
20
|
+
* stripped, even if the operator accidentally lists them.
|
|
21
|
+
*
|
|
22
|
+
* var partial = b.pick(req.body, ["a", "b"], { onUnknown: "throw" });
|
|
23
|
+
* // throws if req.body has any key NOT in ["a", "b"]
|
|
24
|
+
*
|
|
25
|
+
* var nested = b.pick(req.body, [
|
|
26
|
+
* "name",
|
|
27
|
+
* ["profile", ["bio", "url"]], // nested allowlist for `profile.*`
|
|
28
|
+
* ]);
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
var POISONED_KEYS = ["__proto__", "constructor", "prototype"];
|
|
32
|
+
|
|
33
|
+
function _isPlainObject(o) {
|
|
34
|
+
return o !== null && typeof o === "object" && !Array.isArray(o) &&
|
|
35
|
+
(Object.getPrototypeOf(o) === Object.prototype ||
|
|
36
|
+
Object.getPrototypeOf(o) === null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _normalizeAllowList(list) {
|
|
40
|
+
// Accept either ["a","b"] or [["nested",["sub1","sub2"]]] — return
|
|
41
|
+
// a Map<key, allowList | true>.
|
|
42
|
+
var out = Object.create(null);
|
|
43
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
44
|
+
var entry = list[i];
|
|
45
|
+
if (typeof entry === "string") {
|
|
46
|
+
if (POISONED_KEYS.indexOf(entry) !== -1) continue;
|
|
47
|
+
out[entry] = true;
|
|
48
|
+
} else if (Array.isArray(entry) && entry.length === 2 &&
|
|
49
|
+
typeof entry[0] === "string" && Array.isArray(entry[1])) {
|
|
50
|
+
if (POISONED_KEYS.indexOf(entry[0]) !== -1) continue;
|
|
51
|
+
out[entry[0]] = _normalizeAllowList(entry[1]);
|
|
52
|
+
} else {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
"b.pick: allowlist entry must be a string or [name, [...]]; got " +
|
|
55
|
+
JSON.stringify(entry));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _pickInner(input, normalized, onUnknown, path) {
|
|
62
|
+
if (!_isPlainObject(input)) {
|
|
63
|
+
return _isPlainObject(input) ? {} : input;
|
|
64
|
+
}
|
|
65
|
+
var output = Object.create(null);
|
|
66
|
+
var keys = Object.keys(input);
|
|
67
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
68
|
+
var k = keys[i];
|
|
69
|
+
if (POISONED_KEYS.indexOf(k) !== -1) continue;
|
|
70
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, k)) {
|
|
71
|
+
if (onUnknown === "throw") {
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
"b.pick: unknown key '" + (path ? path + "." : "") + k +
|
|
74
|
+
"' not in allowlist");
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
var rule = normalized[k];
|
|
79
|
+
if (rule === true) {
|
|
80
|
+
output[k] = input[k];
|
|
81
|
+
} else {
|
|
82
|
+
// Nested allowlist.
|
|
83
|
+
output[k] = _isPlainObject(input[k])
|
|
84
|
+
? _pickInner(input[k], rule, onUnknown, (path ? path + "." : "") + k)
|
|
85
|
+
: input[k];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Convert to a plain Object (output is currently null-prototype) so
|
|
89
|
+
// downstream JSON serializers / DB drivers see a normal-shape object.
|
|
90
|
+
return Object.assign({}, output);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pick(input, allowList, opts) {
|
|
94
|
+
opts = opts || {};
|
|
95
|
+
if (!Array.isArray(allowList)) {
|
|
96
|
+
throw new TypeError("b.pick: second argument must be an array of allowed keys");
|
|
97
|
+
}
|
|
98
|
+
var onUnknown = opts.onUnknown === "throw" ? "throw" : "drop";
|
|
99
|
+
var normalized = _normalizeAllowList(allowList);
|
|
100
|
+
return _pickInner(input, normalized, onUnknown, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = pick;
|
|
104
|
+
module.exports.pick = pick;
|
|
105
|
+
module.exports.POISONED_KEYS = Object.freeze(POISONED_KEYS.slice());
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* safe-redirect — open-redirect (CWE-601) defense for operator-supplied
|
|
4
|
+
* post-login `?next=` / `?return_to=` parameters and similar redirect
|
|
5
|
+
* targets.
|
|
6
|
+
*
|
|
7
|
+
* The vulnerability: an attacker phishes a victim with a link like
|
|
8
|
+
* `https://app.example.com/login?next=https://attacker.example.com`.
|
|
9
|
+
* After login, a naive `res.writeHead(302, { Location: req.query.next })`
|
|
10
|
+
* sends the user to attacker.example.com under the trust of app.example.com.
|
|
11
|
+
*
|
|
12
|
+
* var safe = b.safeRedirect.resolve(rawNext, {
|
|
13
|
+
* base: "https://app.example.com",
|
|
14
|
+
* allowedOrigins: ["https://app.example.com"],
|
|
15
|
+
* allowedHosts: ["app.example.com"],
|
|
16
|
+
* fallback: "/dashboard",
|
|
17
|
+
* });
|
|
18
|
+
* // → safe path or fallback (never attacker.example.com)
|
|
19
|
+
*
|
|
20
|
+
* Decision rules (in order):
|
|
21
|
+
*
|
|
22
|
+
* 1. rawTarget is null / empty / non-string → fallback
|
|
23
|
+
* 2. rawTarget starts with "//" or "\\" → fallback (protocol-relative
|
|
24
|
+
* open redirect — `//attacker.com/path` interpreted as
|
|
25
|
+
* `https://attacker.com/path` by browsers)
|
|
26
|
+
* 3. rawTarget contains a control char / null / CR / LF → fallback
|
|
27
|
+
* (header-injection vector)
|
|
28
|
+
* 4. rawTarget is a relative path starting with "/" → safe (same-
|
|
29
|
+
* origin by definition)
|
|
30
|
+
* 5. rawTarget is a fragment / search-only ("#x" / "?q=1") → safe
|
|
31
|
+
* 6. rawTarget is a full URL → parse + check origin against
|
|
32
|
+
* allowedOrigins (or host against allowedHosts when the operator
|
|
33
|
+
* doesn't care about scheme/port match)
|
|
34
|
+
* 7. anything else (data:, javascript:, malformed) → fallback
|
|
35
|
+
*
|
|
36
|
+
* Returns the safe URL string (path + query + fragment for relative;
|
|
37
|
+
* full URL for allowed full URLs; fallback otherwise). Operators
|
|
38
|
+
* pass the result directly to `res.writeHead(302, { Location: ... })`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var safeUrl = require("./safe-url");
|
|
42
|
+
var validateOpts = require("./validate-opts");
|
|
43
|
+
|
|
44
|
+
var DEFAULT_FALLBACK = "/";
|
|
45
|
+
|
|
46
|
+
function _hasControlChar(s) {
|
|
47
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
48
|
+
var c = s.charCodeAt(i);
|
|
49
|
+
if (c < 0x20 || c === 0x7f) return true; // allow:raw-byte-literal — ASCII control range thresholds
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolve(rawTarget, opts) {
|
|
55
|
+
opts = opts || {};
|
|
56
|
+
validateOpts(opts, ["base", "allowedOrigins", "allowedHosts", "fallback"], "safeRedirect.resolve");
|
|
57
|
+
|
|
58
|
+
var fallback = typeof opts.fallback === "string" ? opts.fallback : DEFAULT_FALLBACK;
|
|
59
|
+
if (typeof rawTarget !== "string" || rawTarget.length === 0) return fallback;
|
|
60
|
+
if (_hasControlChar(rawTarget)) return fallback;
|
|
61
|
+
|
|
62
|
+
// Reject protocol-relative ("//host/...") and back-slash variant
|
|
63
|
+
// ("\\host\..." — IE / older browsers may interpret as auth).
|
|
64
|
+
if (rawTarget.length >= 2) {
|
|
65
|
+
var p0 = rawTarget.charAt(0);
|
|
66
|
+
var p1 = rawTarget.charAt(1);
|
|
67
|
+
if ((p0 === "/" || p0 === "\\") && (p1 === "/" || p1 === "\\")) return fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Same-origin relative (path / query / fragment) — safe by definition.
|
|
71
|
+
if (rawTarget.charAt(0) === "/" || rawTarget.charAt(0) === "?" ||
|
|
72
|
+
rawTarget.charAt(0) === "#") {
|
|
73
|
+
return rawTarget;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Full URL — parse and check against allowlist.
|
|
77
|
+
var allowedOrigins = Array.isArray(opts.allowedOrigins) ? opts.allowedOrigins : null;
|
|
78
|
+
var allowedHosts = Array.isArray(opts.allowedHosts) ? opts.allowedHosts : null;
|
|
79
|
+
if (!allowedOrigins && !allowedHosts) {
|
|
80
|
+
// Operator gave no allowlist — refuse all full URLs (the safe default).
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var parsed;
|
|
85
|
+
try { parsed = safeUrl.parse(rawTarget, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS }); }
|
|
86
|
+
catch (_e) { return fallback; }
|
|
87
|
+
|
|
88
|
+
if (allowedOrigins) {
|
|
89
|
+
for (var i = 0; i < allowedOrigins.length; i += 1) {
|
|
90
|
+
if (parsed.origin === allowedOrigins[i]) return rawTarget;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (allowedHosts) {
|
|
94
|
+
for (var j = 0; j < allowedHosts.length; j += 1) {
|
|
95
|
+
if (parsed.host === allowedHosts[j] || parsed.hostname === allowedHosts[j]) {
|
|
96
|
+
return rawTarget;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
resolve: resolve,
|
|
105
|
+
DEFAULT_FALLBACK: DEFAULT_FALLBACK,
|
|
106
|
+
};
|
package/lib/static.js
CHANGED
|
@@ -168,19 +168,21 @@ function _resolveSafe(root, requestedPath) {
|
|
|
168
168
|
|
|
169
169
|
// Symlink-escape defense — the lexical resolve above only sees the
|
|
170
170
|
// requested path tokens; a symlink anywhere along `resolved` can
|
|
171
|
-
// still point outside `rootResolved` on disk.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
171
|
+
// still point outside `rootResolved` on disk. Compare via realpath
|
|
172
|
+
// for the escape check ONLY; do NOT substitute the realpath result
|
|
173
|
+
// for `resolved`. Substituting breaks downstream consumers that key
|
|
174
|
+
// on the lexical path (revoke list, ETag cache, audit row) AND
|
|
175
|
+
// breaks deploys where the OS prefix-symlinks the temp dir
|
|
176
|
+
// (macOS: /var/folders/X/Y → /private/var/folders/X/Y).
|
|
174
177
|
try {
|
|
175
178
|
var real = fs.realpathSync(resolved);
|
|
176
179
|
var rootReal = fs.realpathSync(rootResolved);
|
|
177
180
|
if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
|
|
178
|
-
resolved = real;
|
|
179
181
|
} catch (_e) {
|
|
180
182
|
// Path doesn't exist (or is denied) — fall through with the lexical
|
|
181
183
|
// resolution so the caller's stat() returns the natural ENOENT and
|
|
182
184
|
// 404s. realpath failures from non-existence are NOT a smuggling
|
|
183
|
-
// signal.
|
|
185
|
+
// signal; the lexical bound check above already rejected escapes.
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
// Filename safety — the basename gates against path-traversal /
|
package/lib/webhook.js
CHANGED
|
@@ -198,17 +198,37 @@ function _hmacVerify(key, data, expectedHex) {
|
|
|
198
198
|
return crypto.timingSafeEqual(actualHex, expectedHex);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// PQC signatures encode as base64url. SLH-DSA-SHAKE-256f signatures
|
|
202
|
+
// are ~29.5 KB binary → ~59 KB hex but only ~40 KB base64url. The hex
|
|
203
|
+
// form blew past common front-end limits (nginx default 8 KB / Cloudflare
|
|
204
|
+
// default 16 KB / many CDN edge limits 32 KB). base64url keeps the
|
|
205
|
+
// signature in-header for the bulk of operators while still allowing
|
|
206
|
+
// body-bound signatures (operator passes the wire-encoded sig in body
|
|
207
|
+
// when even base64url is too large for their topology).
|
|
208
|
+
//
|
|
209
|
+
// Verification accepts EITHER encoding for a transition window: a
|
|
210
|
+
// base64url-shaped value is decoded as base64url; otherwise a hex-
|
|
211
|
+
// shaped value is decoded as hex. New signatures are emitted as
|
|
212
|
+
// base64url; old hex-encoded signatures still verify.
|
|
201
213
|
function _pqcSign(privateKeyPem, data) {
|
|
202
|
-
return crypto.sign(data, privateKeyPem).toString("
|
|
214
|
+
return crypto.sign(data, privateKeyPem).toString("base64url");
|
|
203
215
|
}
|
|
204
216
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
var _BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
218
|
+
|
|
219
|
+
function _pqcVerify(publicKeyPem, data, expectedSig) {
|
|
220
|
+
if (typeof expectedSig !== "string" || expectedSig.length === 0) return false;
|
|
209
221
|
var sigBuf;
|
|
210
|
-
try {
|
|
211
|
-
|
|
222
|
+
try {
|
|
223
|
+
if (_BASE64URL_RE.test(expectedSig) && // allow:regex-no-length-cap — sig length bounded by header parser cap
|
|
224
|
+
!/^[0-9a-f]+$/.test(expectedSig)) { // allow:regex-no-length-cap — same
|
|
225
|
+
sigBuf = Buffer.from(expectedSig, "base64url");
|
|
226
|
+
} else if (safeBuffer.isHex(expectedSig) && (expectedSig.length % 2) === 0) {
|
|
227
|
+
sigBuf = Buffer.from(expectedSig, "hex");
|
|
228
|
+
} else {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
} catch (_e) { return false; }
|
|
212
232
|
try { return crypto.verify(data, sigBuf, publicKeyPem); }
|
|
213
233
|
catch (_e) { return false; }
|
|
214
234
|
}
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:e04975c4-d3d5-4299-aad3-3e7d44ea1d43",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-05T06:06:29.999Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.21",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.21",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.21",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|