@blamejs/core 0.7.49 → 0.7.51
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 +4 -0
- package/lib/framework-error.js +20 -0
- package/lib/guard-all.js +2 -0
- package/lib/guard-jwt.js +518 -0
- package/lib/guard-oauth.js +401 -0
- 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.51** (2026-05-05) — `b.guardOauth` — OAuth flow-shape safety primitive (KIND="oauth-flow"). Validates user-supplied OAuth 2.x / OIDC authorization-code-flow parameter bundles before the framework's `b.auth.oauth` client exchanges them. Threat catalog: PKCE missing or non-S256 (RFC 7636 / OAuth 2.1 mandate; `plain` is the downgrade-attack class); state missing (RFC 6749 §10.12 CSRF); redirect_uri not in operator allowlist (exact-match per OAuth 2.1 — no prefix / wildcard / scheme drift); response_type allowlist drift (refuse implicit `token` deprecated in OAuth 2.1, require operator-allowed types); scope-token shape per RFC 6749 §3.3 (refuse non-printable / control / whitespace-other-than-space); issuer missing on callback (RFC 9207 IdP-mix-up defense — set `flow._isCallback = true` to enforce); authorization-code reuse via operator-supplied `seenCodeStore.hasSeen(code)` (RFC 6749 §10.5 replay class); excessive parameter / total bytes; BIDI / null / control / zero-width universal refuse. Profiles: `strict` (PKCE S256, state required, redirect_uri exact-match, RFC 9207 iss required), `balanced` (PKCE any method, state + redirect_uri allowlist required, iss audited), `permissive` (universal-refuse class still refused; rest audit). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Adaptive integration harness gains a KIND="oauth-flow" dispatcher reading `ctx.oauthFlow`. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
12
|
+
|
|
13
|
+
- **0.7.50** (2026-05-05) — `b.guardJwt` — JWT identifier-safety primitive (KIND="identifier"). Validates user-supplied JWT compact-serialization strings against the canonical CVE-class refuse list before hand-off to a verifier — `b.guardJwt` never replaces signature verification, it reduces the input space the verifier sees. Threat catalog: shape malformation; `alg=none` (CVE-2015-9235 jsonwebtoken / CVE-2018-0114 java-jwt) — universally refused at every profile; alg-allowlist drift (PQC-first default: ML-DSA / SLH-DSA / EdDSA / ES* / RS* / PS*); `kid` path-traversal (operator `keyResolver` path-injection class — e.g. `kid: "../etc/passwd"` would escape a key-directory `fs.readFile(keyDir + kid)` resolver); `typ` confusion (non-JWT-shape media-type tokens coerced into the slot); oversized header / payload / signature segment defense (decompression bomb + parser DoS); `exp` / `nbf` / `iat` sanity (past `exp` = replay; far-future `nbf` / `iat` = clock-skew / attacker-shaped); required-claims enforcement (default `iss` / `exp` / `iat` at strict); unknown `crit` field refuse (RFC 7515 §4.1.11 — operator MUST refuse crit values it doesn't understand); BIDI / null / control / zero-width universal refuse. **`b.guardJwt.kidSafe(kid)`** is the documented contract for operator `keyResolver` implementations: throws on traversal indicators or control bytes, returns the validated kid on success. Profiles: `strict` (refuse everything), `balanced` (refuse alg=none / kid-traversal / unknown-crit; audit the rest), `permissive` (universal-refuse class still refused). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
14
|
+
|
|
11
15
|
- **0.7.49** (2026-05-05) — `b.middleware.headers(opts)` — inbound HTTP header threat-detection middleware. Sits at the top of the request lifecycle. Threat catalog: `header-name-shape` (header name not a valid RFC 9110 §5.1 token); `header-value-control-byte` (CR / LF / NUL inside any header value — header-injection defense in depth on top of Node's rejection); `header-count-cap` (default 100 inbound headers max); `header-value-cap` (default 8 KiB per value); `smuggling-cl-te` (RFC 9112 §6.1 — both Content-Length and Transfer-Encoding present, the canonical CL.TE / TE.CL request-smuggling vector); `smuggling-cl-multi` / `smuggling-te-multi` (multiple values for either header — proxy-desync class); `deprecated-trust-header` (X-Forwarded-For / -Proto / -Host / -Port / X-Real-IP present without operator-supplied `trustProxy: true` opt — operators should adopt RFC 7239 `Forwarded`). On `mode: "enforce"` + `refuseOnHigh` (default), refuses with HTTP 400 + JSON body listing detected high-severity issues; emits one audit row per issue regardless of mode. Complements the existing per-route smuggling defense in `b.middleware.bodyParser` by running the same check at the top of the chain (covers GET / HEAD requests that don't body-parse).
|
|
12
16
|
|
|
13
17
|
- **0.7.48** (2026-05-05) — `b.cookies.parseSafe(header, opts)` + `b.middleware.cookies(opts)` — inbound cookie-header threat detection. The existing `b.cookies.parse` is lenient (last-write-wins, silent skip on malformed pairs); `parseSafe` returns `{ jar, issues }` and surfaces every detected anomaly: header-cap (oversized Cookie header), header-control-byte (CR / LF / NUL injected through proxy — header-injection prelude class), pair-malformed (missing `=`), pair-empty-name, name-cap (oversized name), value-cap (oversized value), duplicate-name (cookie-tossing class — same name appearing more than once in one Cookie header indicates an attacker-set parent-domain cookie shadowing the legitimate one). The middleware shape (`b.middleware.cookies({ mode, audit, refuseOnHigh })`) wires `parseSafe` into the request lifecycle: populates `req.cookieJar`, emits one audit row per detected issue, and refuses with HTTP 400 on any high-severity issue when `mode: "enforce"` (default). Existing `b.cookies` invariants (RFC 6265bis token grammar enforcement, `__Host-` / `__Secure-` prefix invariants, SameSite=None requires Secure, `Partitioned` / CHIPS attribute support, length caps on serialize-side) remain unchanged — this slice closes the inbound-detection gap.
|
package/index.js
CHANGED
|
@@ -115,6 +115,8 @@ var guardUuid = require("./lib/guard-uuid");
|
|
|
115
115
|
var guardCidr = require("./lib/guard-cidr");
|
|
116
116
|
var guardTime = require("./lib/guard-time");
|
|
117
117
|
var guardMime = require("./lib/guard-mime");
|
|
118
|
+
var guardJwt = require("./lib/guard-jwt");
|
|
119
|
+
var guardOauth = require("./lib/guard-oauth");
|
|
118
120
|
var guardAll = require("./lib/guard-all");
|
|
119
121
|
var ssrfGuard = require("./lib/ssrf-guard");
|
|
120
122
|
var authHeader = require("./lib/auth-header");
|
|
@@ -261,6 +263,8 @@ module.exports = {
|
|
|
261
263
|
guardCidr: guardCidr,
|
|
262
264
|
guardTime: guardTime,
|
|
263
265
|
guardMime: guardMime,
|
|
266
|
+
guardJwt: guardJwt,
|
|
267
|
+
guardOauth: guardOauth,
|
|
264
268
|
guardAll: guardAll,
|
|
265
269
|
ssrfGuard: ssrfGuard,
|
|
266
270
|
authHeader: authHeader,
|
package/lib/framework-error.js
CHANGED
|
@@ -288,6 +288,24 @@ var GuardTimeError = defineClass("GuardTimeError", { alwaysPermane
|
|
|
288
288
|
// script-host content types), BIDI / zero-width / control / null-byte
|
|
289
289
|
// universal refuse. alwaysPermanent.
|
|
290
290
|
var GuardMimeError = defineClass("GuardMimeError", { alwaysPermanent: true });
|
|
291
|
+
// GuardJwtError covers JWT identifier violations: shape malformation
|
|
292
|
+
// (not 3 base64url segments), alg=none refuse (canonical CVE-class —
|
|
293
|
+
// CVE-2015-9235 jsonwebtoken / CVE-2018-0114 java-jwt), alg-allowlist
|
|
294
|
+
// drift, kid path-traversal (operator keyResolver path-injection
|
|
295
|
+
// class), typ confusion, oversized header / payload / signature,
|
|
296
|
+
// exp / nbf / iat sanity, missing required claims, unknown crit
|
|
297
|
+
// fields (RFC 7515 §4.1.11), BIDI / null / control / zero-width
|
|
298
|
+
// universal refuse. alwaysPermanent.
|
|
299
|
+
var GuardJwtError = defineClass("GuardJwtError", { alwaysPermanent: true });
|
|
300
|
+
// GuardOauthError covers OAuth flow-shape violations: PKCE missing /
|
|
301
|
+
// non-S256 (downgrade-attack class), state missing (RFC 6749 §10.12
|
|
302
|
+
// CSRF class), redirect_uri not in operator allowlist (exact-match
|
|
303
|
+
// per OAuth 2.1), response_type allowlist drift, scope-token shape
|
|
304
|
+
// (RFC 6749 §3.3), issuer missing on callback (RFC 9207 IdP-mix-up),
|
|
305
|
+
// authorization-code reuse (RFC 6749 §10.5), oversized parameter,
|
|
306
|
+
// BIDI / null / control / zero-width universal refuse.
|
|
307
|
+
// alwaysPermanent.
|
|
308
|
+
var GuardOauthError = defineClass("GuardOauthError", { alwaysPermanent: true });
|
|
291
309
|
// DoraError covers DORA Article 17 incident-reporting workflow errors
|
|
292
310
|
// (classification refusal, report-shape validation, ESA-template
|
|
293
311
|
// generation, audit-chain integration). Permanent — these are
|
|
@@ -352,6 +370,8 @@ module.exports = {
|
|
|
352
370
|
GuardCidrError: GuardCidrError,
|
|
353
371
|
GuardTimeError: GuardTimeError,
|
|
354
372
|
GuardMimeError: GuardMimeError,
|
|
373
|
+
GuardJwtError: GuardJwtError,
|
|
374
|
+
GuardOauthError: GuardOauthError,
|
|
355
375
|
DoraError: DoraError,
|
|
356
376
|
ComplianceError: ComplianceError,
|
|
357
377
|
SmtpPolicyError: SmtpPolicyError,
|
package/lib/guard-all.js
CHANGED
package/lib/guard-jwt.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-jwt — JWT identifier-safety primitive (b.guardJwt).
|
|
4
|
+
*
|
|
5
|
+
* Validates user-supplied JWT compact-serialization strings against
|
|
6
|
+
* the canonical CVE-class refuse list before hand-off to a verifier.
|
|
7
|
+
* KIND="identifier" — consumes ctx.identifier (or ctx.token).
|
|
8
|
+
*
|
|
9
|
+
* Threat catalog:
|
|
10
|
+
* - Shape malformation — not 3 dot-separated base64url segments
|
|
11
|
+
* (RFC 7515 §3 / RFC 7519 §3 compact serialization).
|
|
12
|
+
* - alg=none — RFC 7518 §3.6 explicit "no signature" — universally
|
|
13
|
+
* refused; the canonical alg-confusion CVE class
|
|
14
|
+
* (CVE-2015-9235 jsonwebtoken; CVE-2018-0114 java-jwt).
|
|
15
|
+
* - alg algorithm-confusion — operator's verifier may treat HS256
|
|
16
|
+
* with an RSA public key as HMAC, allowing forgery; flag any
|
|
17
|
+
* unexpected alg.
|
|
18
|
+
* - kid path traversal — kid header used by some operators to
|
|
19
|
+
* resolve key files; `..` / `/` / null-byte in kid would escape
|
|
20
|
+
* the keystore directory.
|
|
21
|
+
* - typ confusion — typ != "jwt" / "JWT" / "JWS" indicates a non-
|
|
22
|
+
* JWT token coerced into the slot.
|
|
23
|
+
* - Oversized header / payload / signature — defense against
|
|
24
|
+
* decompression bombs and parser DoS.
|
|
25
|
+
* - exp / nbf / iat sanity — exp in the past, nbf in the far
|
|
26
|
+
* future, iat way in the future all indicate replay or clock-
|
|
27
|
+
* skew issues.
|
|
28
|
+
* - Unknown crit fields — RFC 7515 §4.1.11 — operator MUST refuse
|
|
29
|
+
* tokens carrying crit headers it doesn't understand.
|
|
30
|
+
* - BIDI / null / control / zero-width universal refuse.
|
|
31
|
+
*
|
|
32
|
+
* var rv = b.guardJwt.validate(jwtString, { profile: "strict" });
|
|
33
|
+
* var g = b.guardJwt.gate({ profile: "strict" });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var codepointClass = require("./codepoint-class");
|
|
37
|
+
var lazyRequire = require("./lazy-require");
|
|
38
|
+
var gateContract = require("./gate-contract");
|
|
39
|
+
var C = require("./constants");
|
|
40
|
+
var numericBounds = require("./numeric-bounds");
|
|
41
|
+
var safeJson = require("./safe-json");
|
|
42
|
+
var { GuardJwtError } = require("./framework-error");
|
|
43
|
+
|
|
44
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
45
|
+
void observability;
|
|
46
|
+
|
|
47
|
+
var _err = GuardJwtError.factory;
|
|
48
|
+
|
|
49
|
+
// JWT compact serialization shape — three base64url segments separated
|
|
50
|
+
// by dots. base64url alphabet is A-Z / a-z / 0-9 / `-` / `_`.
|
|
51
|
+
var JWT_SHAPE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
52
|
+
|
|
53
|
+
// kid path-traversal indicators.
|
|
54
|
+
var KID_TRAVERSAL_RE = /\.\.|\/|\\|%2e%2e|%2f|%5c/i;
|
|
55
|
+
|
|
56
|
+
// Default operator-allowed alg list — PQC-first per the framework.
|
|
57
|
+
var DEFAULT_ALLOWED_ALGS = Object.freeze([
|
|
58
|
+
"ML-DSA-87", "ML-DSA-65", "ML-DSA-44",
|
|
59
|
+
"SLH-DSA-SHAKE-256f", "SLH-DSA-SHAKE-256s",
|
|
60
|
+
"SLH-DSA-SHA2-256f", "SLH-DSA-SHA2-256s",
|
|
61
|
+
"EdDSA", "ES256", "ES384", "ES512",
|
|
62
|
+
"RS256", "RS384", "RS512",
|
|
63
|
+
"PS256", "PS384", "PS512",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function _b64urlDecodeJson(seg) {
|
|
67
|
+
if (!seg) return null;
|
|
68
|
+
var pad = (4 - (seg.length % 4)) % 4;
|
|
69
|
+
var b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);
|
|
70
|
+
try {
|
|
71
|
+
var json = Buffer.from(b64, "base64").toString("utf8");
|
|
72
|
+
return safeJson.parse(json, { rejectProto: true });
|
|
73
|
+
} catch (_e) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- Profile presets ----
|
|
79
|
+
|
|
80
|
+
var PROFILES = Object.freeze({
|
|
81
|
+
"strict": {
|
|
82
|
+
bidiPolicy: "reject",
|
|
83
|
+
controlPolicy: "reject",
|
|
84
|
+
nullBytePolicy: "reject",
|
|
85
|
+
zeroWidthPolicy: "reject",
|
|
86
|
+
algNonePolicy: "reject",
|
|
87
|
+
algAllowlistPolicy: "reject",
|
|
88
|
+
kidTraversalPolicy: "reject",
|
|
89
|
+
typConfusionPolicy: "reject",
|
|
90
|
+
expSanityPolicy: "reject",
|
|
91
|
+
nbfSanityPolicy: "reject",
|
|
92
|
+
iatSanityPolicy: "reject",
|
|
93
|
+
critUnknownPolicy: "reject",
|
|
94
|
+
allowedAlgs: DEFAULT_ALLOWED_ALGS,
|
|
95
|
+
requiredClaims: ["iss", "exp", "iat"],
|
|
96
|
+
knownCrit: [], // empty — every crit field is unknown by default
|
|
97
|
+
nbfFutureSlackMs: C.TIME.minutes(5),
|
|
98
|
+
iatFutureSlackMs: C.TIME.minutes(5),
|
|
99
|
+
maxHeaderBytes: C.BYTES.kib(2),
|
|
100
|
+
maxPayloadBytes: C.BYTES.kib(8),
|
|
101
|
+
maxSignatureBytes: C.BYTES.kib(4),
|
|
102
|
+
maxBytes: C.BYTES.kib(16),
|
|
103
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
104
|
+
},
|
|
105
|
+
"balanced": {
|
|
106
|
+
bidiPolicy: "reject",
|
|
107
|
+
controlPolicy: "reject",
|
|
108
|
+
nullBytePolicy: "reject",
|
|
109
|
+
zeroWidthPolicy: "reject",
|
|
110
|
+
algNonePolicy: "reject", // alg=none refused at every profile
|
|
111
|
+
algAllowlistPolicy: "audit",
|
|
112
|
+
kidTraversalPolicy: "reject", // kid traversal refused at every profile
|
|
113
|
+
typConfusionPolicy: "audit",
|
|
114
|
+
expSanityPolicy: "audit",
|
|
115
|
+
nbfSanityPolicy: "audit",
|
|
116
|
+
iatSanityPolicy: "audit",
|
|
117
|
+
critUnknownPolicy: "reject", // unknown crit refused at every profile (RFC 7515)
|
|
118
|
+
allowedAlgs: DEFAULT_ALLOWED_ALGS,
|
|
119
|
+
requiredClaims: ["iss", "exp"],
|
|
120
|
+
knownCrit: [],
|
|
121
|
+
nbfFutureSlackMs: C.TIME.minutes(15),
|
|
122
|
+
iatFutureSlackMs: C.TIME.minutes(15),
|
|
123
|
+
maxHeaderBytes: C.BYTES.kib(2),
|
|
124
|
+
maxPayloadBytes: C.BYTES.kib(32),
|
|
125
|
+
maxSignatureBytes: C.BYTES.kib(8),
|
|
126
|
+
maxBytes: C.BYTES.kib(64),
|
|
127
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
128
|
+
},
|
|
129
|
+
"permissive": {
|
|
130
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
131
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
132
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
133
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
134
|
+
algNonePolicy: "reject", // alg=none refused at every profile
|
|
135
|
+
algAllowlistPolicy: "allow",
|
|
136
|
+
kidTraversalPolicy: "reject", // kid traversal refused at every profile
|
|
137
|
+
typConfusionPolicy: "audit",
|
|
138
|
+
expSanityPolicy: "audit",
|
|
139
|
+
nbfSanityPolicy: "audit",
|
|
140
|
+
iatSanityPolicy: "audit",
|
|
141
|
+
critUnknownPolicy: "reject", // unknown crit refused at every profile
|
|
142
|
+
allowedAlgs: null,
|
|
143
|
+
requiredClaims: [],
|
|
144
|
+
knownCrit: [],
|
|
145
|
+
nbfFutureSlackMs: C.TIME.hours(1),
|
|
146
|
+
iatFutureSlackMs: C.TIME.hours(1),
|
|
147
|
+
maxHeaderBytes: C.BYTES.kib(4),
|
|
148
|
+
maxPayloadBytes: C.BYTES.kib(64),
|
|
149
|
+
maxSignatureBytes: C.BYTES.kib(16),
|
|
150
|
+
maxBytes: C.BYTES.kib(128),
|
|
151
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
156
|
+
mode: "enforce",
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
160
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
161
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
162
|
+
}),
|
|
163
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
164
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
165
|
+
}),
|
|
166
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
167
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
168
|
+
}),
|
|
169
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
170
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
function _resolveOpts(opts) {
|
|
175
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
176
|
+
profiles: PROFILES,
|
|
177
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
178
|
+
defaults: DEFAULTS,
|
|
179
|
+
errorClass: GuardJwtError,
|
|
180
|
+
errCodePrefix: "jwt",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _detectIssues(input, opts) {
|
|
185
|
+
var issues = [];
|
|
186
|
+
if (typeof input !== "string") {
|
|
187
|
+
return [{ kind: "bad-input", severity: "high",
|
|
188
|
+
ruleId: "jwt.bad-input",
|
|
189
|
+
snippet: "jwt is not a string" }];
|
|
190
|
+
}
|
|
191
|
+
if (input.length === 0) {
|
|
192
|
+
return [{ kind: "empty", severity: "high",
|
|
193
|
+
ruleId: "jwt.empty",
|
|
194
|
+
snippet: "jwt is empty" }];
|
|
195
|
+
}
|
|
196
|
+
if (Buffer.byteLength(input, "utf8") > opts.maxBytes) {
|
|
197
|
+
return [{ kind: "jwt-cap", severity: "high",
|
|
198
|
+
ruleId: "jwt.jwt-cap",
|
|
199
|
+
snippet: "jwt input exceeds maxBytes " + opts.maxBytes }];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var charThreats = codepointClass.detectCharThreats(input, opts, "jwt");
|
|
203
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
204
|
+
|
|
205
|
+
if (!JWT_SHAPE_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes
|
|
206
|
+
issues.push({
|
|
207
|
+
kind: "jwt-shape", severity: "high",
|
|
208
|
+
ruleId: "jwt.jwt-shape",
|
|
209
|
+
snippet: "input does not match JWT compact-serialization shape " +
|
|
210
|
+
"(three base64url segments separated by dots)",
|
|
211
|
+
});
|
|
212
|
+
return issues;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
var segments = input.split(".");
|
|
216
|
+
var headerSeg = segments[0];
|
|
217
|
+
var payloadSeg = segments[1];
|
|
218
|
+
var signatureSeg = segments[2];
|
|
219
|
+
|
|
220
|
+
if (Buffer.byteLength(headerSeg, "utf8") > opts.maxHeaderBytes) {
|
|
221
|
+
issues.push({
|
|
222
|
+
kind: "header-cap", severity: "high",
|
|
223
|
+
ruleId: "jwt.header-cap",
|
|
224
|
+
snippet: "JWT header segment exceeds maxHeaderBytes " +
|
|
225
|
+
opts.maxHeaderBytes,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (Buffer.byteLength(payloadSeg, "utf8") > opts.maxPayloadBytes) {
|
|
229
|
+
issues.push({
|
|
230
|
+
kind: "payload-cap", severity: "high",
|
|
231
|
+
ruleId: "jwt.payload-cap",
|
|
232
|
+
snippet: "JWT payload segment exceeds maxPayloadBytes " +
|
|
233
|
+
opts.maxPayloadBytes,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (Buffer.byteLength(signatureSeg, "utf8") > opts.maxSignatureBytes) {
|
|
237
|
+
issues.push({
|
|
238
|
+
kind: "signature-cap", severity: "high",
|
|
239
|
+
ruleId: "jwt.signature-cap",
|
|
240
|
+
snippet: "JWT signature segment exceeds maxSignatureBytes " +
|
|
241
|
+
opts.maxSignatureBytes,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
var header = _b64urlDecodeJson(headerSeg);
|
|
246
|
+
if (!header || typeof header !== "object") {
|
|
247
|
+
issues.push({
|
|
248
|
+
kind: "header-decode", severity: "high",
|
|
249
|
+
ruleId: "jwt.header-decode",
|
|
250
|
+
snippet: "JWT header is not decodable JSON or contains " +
|
|
251
|
+
"prototype-pollution keys",
|
|
252
|
+
});
|
|
253
|
+
return issues;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// alg=none — universal refuse.
|
|
257
|
+
if (typeof header.alg === "string" &&
|
|
258
|
+
header.alg.toLowerCase() === "none") {
|
|
259
|
+
issues.push({
|
|
260
|
+
kind: "alg-none", severity: "critical",
|
|
261
|
+
ruleId: "jwt.alg-none",
|
|
262
|
+
snippet: "JWT header alg=none — RFC 7518 §3.6 explicit-no-signature; " +
|
|
263
|
+
"canonical CVE-class refuse",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// alg allowlist enforcement.
|
|
267
|
+
if (opts.algAllowlistPolicy !== "allow" &&
|
|
268
|
+
opts.allowedAlgs && Array.isArray(opts.allowedAlgs)) {
|
|
269
|
+
if (typeof header.alg !== "string" ||
|
|
270
|
+
opts.allowedAlgs.indexOf(header.alg) === -1) {
|
|
271
|
+
issues.push({
|
|
272
|
+
kind: "alg-not-allowed",
|
|
273
|
+
severity: opts.algAllowlistPolicy === "reject" ? "high" : "warn",
|
|
274
|
+
ruleId: "jwt.alg-not-allowed",
|
|
275
|
+
snippet: "JWT alg `" + (header.alg || "<missing>") + "` not in " +
|
|
276
|
+
"operator allowlist (" + opts.allowedAlgs.length +
|
|
277
|
+
" entries)",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// kid path-traversal.
|
|
283
|
+
if (typeof header.kid === "string" &&
|
|
284
|
+
opts.kidTraversalPolicy !== "allow" &&
|
|
285
|
+
KID_TRAVERSAL_RE.test(header.kid)) { // allow:regex-no-length-cap — header object size bounded by maxHeaderBytes
|
|
286
|
+
issues.push({
|
|
287
|
+
kind: "kid-traversal", severity: "critical",
|
|
288
|
+
ruleId: "jwt.kid-traversal",
|
|
289
|
+
snippet: "JWT kid `" + header.kid + "` contains path-traversal " +
|
|
290
|
+
"indicators (`..`, `/`, `\\`, percent-encoded forms) — " +
|
|
291
|
+
"operator keyResolver MUST sanitize before file-system " +
|
|
292
|
+
"use",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// typ confusion.
|
|
297
|
+
if (typeof header.typ === "string" &&
|
|
298
|
+
opts.typConfusionPolicy !== "allow") {
|
|
299
|
+
var typLow = header.typ.toLowerCase();
|
|
300
|
+
if (typLow !== "jwt" && typLow !== "jws" && typLow !== "at+jwt" &&
|
|
301
|
+
typLow !== "id_token") {
|
|
302
|
+
issues.push({
|
|
303
|
+
kind: "typ-confusion",
|
|
304
|
+
severity: opts.typConfusionPolicy === "reject" ? "high" : "warn",
|
|
305
|
+
ruleId: "jwt.typ-confusion",
|
|
306
|
+
snippet: "JWT typ `" + header.typ + "` is not a known JWT-shape " +
|
|
307
|
+
"media-type token",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Unknown crit fields.
|
|
313
|
+
if (Array.isArray(header.crit) && opts.critUnknownPolicy !== "allow") {
|
|
314
|
+
var known = opts.knownCrit || [];
|
|
315
|
+
for (var ki = 0; ki < header.crit.length; ki += 1) {
|
|
316
|
+
var c = header.crit[ki];
|
|
317
|
+
if (known.indexOf(c) === -1) {
|
|
318
|
+
issues.push({
|
|
319
|
+
kind: "crit-unknown",
|
|
320
|
+
severity: opts.critUnknownPolicy === "reject" ? "high" : "warn",
|
|
321
|
+
ruleId: "jwt.crit-unknown",
|
|
322
|
+
snippet: "JWT crit `" + c + "` is not in operator's knownCrit " +
|
|
323
|
+
"allowlist (RFC 7515 §4.1.11 requires refusing unknown crit)",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Payload claim sanity (only if payload is decodable).
|
|
330
|
+
var payload = _b64urlDecodeJson(payloadSeg);
|
|
331
|
+
if (payload && typeof payload === "object") {
|
|
332
|
+
var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — seconds-per-millisecond conversion
|
|
333
|
+
|
|
334
|
+
// exp in the past.
|
|
335
|
+
if (typeof payload.exp === "number" &&
|
|
336
|
+
opts.expSanityPolicy !== "allow") {
|
|
337
|
+
if (payload.exp < nowSec) {
|
|
338
|
+
issues.push({
|
|
339
|
+
kind: "exp-past",
|
|
340
|
+
severity: opts.expSanityPolicy === "reject" ? "high" : "warn",
|
|
341
|
+
ruleId: "jwt.exp-past",
|
|
342
|
+
snippet: "JWT exp " + payload.exp + " is in the past " +
|
|
343
|
+
"(now=" + nowSec + ")",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// nbf far-future.
|
|
349
|
+
if (typeof payload.nbf === "number" &&
|
|
350
|
+
opts.nbfSanityPolicy !== "allow") {
|
|
351
|
+
var nbfSlackSec = Math.floor(opts.nbfFutureSlackMs / 1000); // allow:raw-byte-literal — seconds-per-millisecond conversion
|
|
352
|
+
if (payload.nbf > nowSec + nbfSlackSec) {
|
|
353
|
+
issues.push({
|
|
354
|
+
kind: "nbf-far-future",
|
|
355
|
+
severity: opts.nbfSanityPolicy === "reject" ? "high" : "warn",
|
|
356
|
+
ruleId: "jwt.nbf-far-future",
|
|
357
|
+
snippet: "JWT nbf " + payload.nbf + " is more than " +
|
|
358
|
+
nbfSlackSec + " seconds in the future",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// iat far-future.
|
|
364
|
+
if (typeof payload.iat === "number" &&
|
|
365
|
+
opts.iatSanityPolicy !== "allow") {
|
|
366
|
+
var iatSlackSec = Math.floor(opts.iatFutureSlackMs / 1000); // allow:raw-byte-literal — seconds-per-millisecond conversion
|
|
367
|
+
if (payload.iat > nowSec + iatSlackSec) {
|
|
368
|
+
issues.push({
|
|
369
|
+
kind: "iat-far-future",
|
|
370
|
+
severity: opts.iatSanityPolicy === "reject" ? "high" : "warn",
|
|
371
|
+
ruleId: "jwt.iat-far-future",
|
|
372
|
+
snippet: "JWT iat " + payload.iat + " is more than " +
|
|
373
|
+
iatSlackSec + " seconds in the future",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Required claims.
|
|
379
|
+
if (Array.isArray(opts.requiredClaims)) {
|
|
380
|
+
for (var rci = 0; rci < opts.requiredClaims.length; rci += 1) {
|
|
381
|
+
var c2 = opts.requiredClaims[rci];
|
|
382
|
+
if (payload[c2] === undefined) {
|
|
383
|
+
issues.push({
|
|
384
|
+
kind: "claim-missing", severity: "high",
|
|
385
|
+
ruleId: "jwt.claim-missing",
|
|
386
|
+
snippet: "JWT payload missing required claim `" + c2 + "`",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return issues;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function validate(input, opts) {
|
|
397
|
+
opts = _resolveOpts(opts);
|
|
398
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
399
|
+
["maxBytes", "maxHeaderBytes", "maxPayloadBytes", "maxSignatureBytes",
|
|
400
|
+
"nbfFutureSlackMs", "iatFutureSlackMs"],
|
|
401
|
+
"guardJwt.validate", GuardJwtError, "jwt.bad-opt");
|
|
402
|
+
if (typeof input !== "string") {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
406
|
+
ruleId: "jwt.bad-input",
|
|
407
|
+
snippet: "jwt is not a string" }],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sanitize(input, opts) {
|
|
414
|
+
opts = _resolveOpts(opts);
|
|
415
|
+
if (typeof input !== "string") {
|
|
416
|
+
throw _err("jwt.bad-input", "sanitize requires string input");
|
|
417
|
+
}
|
|
418
|
+
// JWT shape can't be repaired — sanitize either passes through
|
|
419
|
+
// valid input or throws.
|
|
420
|
+
var issues = _detectIssues(input, opts);
|
|
421
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
422
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
423
|
+
throw _err(issues[i].ruleId || "jwt.refused",
|
|
424
|
+
"guardJwt.sanitize: " + issues[i].snippet);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return input;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function gate(opts) {
|
|
431
|
+
opts = _resolveOpts(opts);
|
|
432
|
+
return gateContract.buildGuardGate(
|
|
433
|
+
opts.name || "guardJwt:" + (opts.profile || "default"),
|
|
434
|
+
opts,
|
|
435
|
+
async function (ctx) {
|
|
436
|
+
var identifier = ctx && (ctx.identifier || ctx.token || ctx.jwt || "");
|
|
437
|
+
if (!identifier) return { ok: true, action: "serve" };
|
|
438
|
+
var rv = validate(identifier, opts);
|
|
439
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
440
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
441
|
+
return i.severity === "critical";
|
|
442
|
+
});
|
|
443
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
444
|
+
return i.severity === "high";
|
|
445
|
+
});
|
|
446
|
+
if (!hasCritical && !hasHigh) {
|
|
447
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
448
|
+
}
|
|
449
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
454
|
+
|
|
455
|
+
function compliancePosture(name) {
|
|
456
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
457
|
+
_err, "jwt");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
var _jwtRulePacks = gateContract.makeRulePackLoader(GuardJwtError, "jwt");
|
|
461
|
+
var loadRulePack = _jwtRulePacks.load;
|
|
462
|
+
|
|
463
|
+
// Operator helper — `kidSafe(kid)` throws on traversal indicators.
|
|
464
|
+
// Documented as the contract for keyResolver implementations.
|
|
465
|
+
function kidSafe(kid) {
|
|
466
|
+
if (typeof kid !== "string" || kid.length === 0) {
|
|
467
|
+
throw _err("jwt.kid-empty", "kid must be a non-empty string");
|
|
468
|
+
}
|
|
469
|
+
if (KID_TRAVERSAL_RE.test(kid)) { // allow:regex-no-length-cap — operator-supplied kid; bounded by upstream JWT size cap
|
|
470
|
+
throw _err("jwt.kid-traversal",
|
|
471
|
+
"kid `" + kid + "` contains path-traversal indicators");
|
|
472
|
+
}
|
|
473
|
+
for (var i = 0; i < kid.length; i += 1) {
|
|
474
|
+
var cc = kid.charCodeAt(i);
|
|
475
|
+
if (cc < 0x20 || cc === 0x7F) { // allow:raw-byte-literal — control-byte boundary check
|
|
476
|
+
throw _err("jwt.kid-control",
|
|
477
|
+
"kid contains control byte at index " + i);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return kid;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = {
|
|
484
|
+
// ---- guard-* family registry exports ----
|
|
485
|
+
NAME: "jwt",
|
|
486
|
+
KIND: "identifier",
|
|
487
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
488
|
+
kind: "identifier",
|
|
489
|
+
// Benign: minimal v4 token with alg=ES256, valid JSON header / payload.
|
|
490
|
+
benignBytes: Buffer.from(
|
|
491
|
+
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
|
|
492
|
+
"eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9." +
|
|
493
|
+
"sig", "utf8"),
|
|
494
|
+
benignIdentifier:
|
|
495
|
+
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
|
|
496
|
+
"eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9." +
|
|
497
|
+
"sig",
|
|
498
|
+
// Hostile: alg=none — universal refuse class.
|
|
499
|
+
hostileBytes: Buffer.from(
|
|
500
|
+
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0." +
|
|
501
|
+
"eyJzdWIiOiJhdHRhY2tlciIsImV4cCI6OTk5OTk5OTk5OX0.", "utf8"),
|
|
502
|
+
hostileIdentifier:
|
|
503
|
+
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0." +
|
|
504
|
+
"eyJzdWIiOiJhdHRhY2tlciIsImV4cCI6OTk5OTk5OTk5OX0.",
|
|
505
|
+
}),
|
|
506
|
+
// ---- primitive surface ----
|
|
507
|
+
validate: validate,
|
|
508
|
+
sanitize: sanitize,
|
|
509
|
+
gate: gate,
|
|
510
|
+
kidSafe: kidSafe,
|
|
511
|
+
buildProfile: buildProfile,
|
|
512
|
+
compliancePosture: compliancePosture,
|
|
513
|
+
loadRulePack: loadRulePack,
|
|
514
|
+
PROFILES: PROFILES,
|
|
515
|
+
DEFAULTS: DEFAULTS,
|
|
516
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
517
|
+
GuardJwtError: GuardJwtError,
|
|
518
|
+
};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-oauth — OAuth flow-shape safety primitive (b.guardOauth).
|
|
4
|
+
*
|
|
5
|
+
* Validates user-supplied OAuth 2.x / OIDC authorization-code-flow
|
|
6
|
+
* parameter bundles before the framework's b.auth.oauth client
|
|
7
|
+
* exchanges them. KIND="oauth-flow" — consumes ctx.oauthFlow.
|
|
8
|
+
*
|
|
9
|
+
* Threat catalog:
|
|
10
|
+
* - PKCE missing or non-S256 — RFC 7636 mandates code_verifier;
|
|
11
|
+
* OAuth 2.1 mandates S256 (no plain). The plaintext "plain"
|
|
12
|
+
* method is downgrade-attack class.
|
|
13
|
+
* - state missing / replayed — RFC 6749 §10.12 + §10.14; without
|
|
14
|
+
* state-binding the flow is open to CSRF.
|
|
15
|
+
* - redirect_uri not in allowlist — RFC 6749 §3.1.2 + OAuth 2.1
|
|
16
|
+
* mandate exact-match (no prefix / wildcard / scheme drift).
|
|
17
|
+
* - response_type not in allowlist — refuse "token" implicit flow
|
|
18
|
+
* (deprecated in OAuth 2.1) and "id_token" outside OIDC; require
|
|
19
|
+
* operator-allowed types.
|
|
20
|
+
* - scope tampering — refuse scope values containing whitespace
|
|
21
|
+
* other than space (RFC 6749 §3.3) or non-printable bytes.
|
|
22
|
+
* - issuer (iss) missing on callback — RFC 9207 mandates iss
|
|
23
|
+
* parameter on authorization response to defeat the IdP-mix-up
|
|
24
|
+
* attack.
|
|
25
|
+
* - code reuse — operator-supplied seenCodeStore detects
|
|
26
|
+
* authorization-code replay (RFC 6749 §10.5).
|
|
27
|
+
* - excessive parameter / value length — defense against parser
|
|
28
|
+
* DoS and decompression-bomb-shaped clients.
|
|
29
|
+
* - BIDI / null / control / zero-width universal refuse.
|
|
30
|
+
*
|
|
31
|
+
* var rv = b.guardOauth.validate({ redirect_uri, state, ... },
|
|
32
|
+
* { profile: "strict" });
|
|
33
|
+
* var g = b.guardOauth.gate({ profile: "strict" });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var codepointClass = require("./codepoint-class");
|
|
37
|
+
var lazyRequire = require("./lazy-require");
|
|
38
|
+
var gateContract = require("./gate-contract");
|
|
39
|
+
var C = require("./constants");
|
|
40
|
+
var numericBounds = require("./numeric-bounds");
|
|
41
|
+
var { GuardOauthError } = require("./framework-error");
|
|
42
|
+
|
|
43
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
44
|
+
void observability;
|
|
45
|
+
|
|
46
|
+
var _err = GuardOauthError.factory;
|
|
47
|
+
|
|
48
|
+
var SCOPE_TOKEN_RE = /^[\x21\x23-\x5b\x5d-\x7e]+$/; // allow:raw-byte-literal — RFC 6749 §3.3 scope-token charset
|
|
49
|
+
var DEFAULT_RESPONSE_TYPES = Object.freeze(["code"]);
|
|
50
|
+
|
|
51
|
+
// ---- Profile presets ----
|
|
52
|
+
|
|
53
|
+
var PROFILES = Object.freeze({
|
|
54
|
+
"strict": {
|
|
55
|
+
bidiPolicy: "reject",
|
|
56
|
+
controlPolicy: "reject",
|
|
57
|
+
nullBytePolicy: "reject",
|
|
58
|
+
zeroWidthPolicy: "reject",
|
|
59
|
+
pkcePolicy: "require-s256",
|
|
60
|
+
statePolicy: "require",
|
|
61
|
+
redirectUriPolicy: "require-exact-allowlist",
|
|
62
|
+
responseTypePolicy: "require-allowlist",
|
|
63
|
+
scopeTamperingPolicy: "reject",
|
|
64
|
+
issuerOnCallbackPolicy: "require", // RFC 9207
|
|
65
|
+
codeReusePolicy: "reject",
|
|
66
|
+
allowedResponseTypes: DEFAULT_RESPONSE_TYPES,
|
|
67
|
+
maxParamBytes: C.BYTES.kib(2),
|
|
68
|
+
maxBytes: C.BYTES.kib(8),
|
|
69
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
70
|
+
},
|
|
71
|
+
"balanced": {
|
|
72
|
+
bidiPolicy: "reject",
|
|
73
|
+
controlPolicy: "reject",
|
|
74
|
+
nullBytePolicy: "reject",
|
|
75
|
+
zeroWidthPolicy: "reject",
|
|
76
|
+
pkcePolicy: "require-any", // S256 or plain
|
|
77
|
+
statePolicy: "require",
|
|
78
|
+
redirectUriPolicy: "require-exact-allowlist",
|
|
79
|
+
responseTypePolicy: "require-allowlist",
|
|
80
|
+
scopeTamperingPolicy: "reject",
|
|
81
|
+
issuerOnCallbackPolicy: "audit",
|
|
82
|
+
codeReusePolicy: "reject",
|
|
83
|
+
allowedResponseTypes: Object.freeze(["code", "code id_token"]),
|
|
84
|
+
maxParamBytes: C.BYTES.kib(2),
|
|
85
|
+
maxBytes: C.BYTES.kib(8),
|
|
86
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
87
|
+
},
|
|
88
|
+
"permissive": {
|
|
89
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
90
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
91
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
92
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
93
|
+
pkcePolicy: "audit",
|
|
94
|
+
statePolicy: "audit",
|
|
95
|
+
redirectUriPolicy: "audit",
|
|
96
|
+
responseTypePolicy: "audit",
|
|
97
|
+
scopeTamperingPolicy: "reject", // scope tampering refused at every profile
|
|
98
|
+
issuerOnCallbackPolicy: "audit",
|
|
99
|
+
codeReusePolicy: "reject", // code reuse refused at every profile
|
|
100
|
+
allowedResponseTypes: null,
|
|
101
|
+
maxParamBytes: C.BYTES.kib(4),
|
|
102
|
+
maxBytes: C.BYTES.kib(16),
|
|
103
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
108
|
+
mode: "enforce",
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
112
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
113
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
114
|
+
}),
|
|
115
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
116
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
117
|
+
}),
|
|
118
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
119
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
120
|
+
}),
|
|
121
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
122
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function _resolveOpts(opts) {
|
|
127
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
128
|
+
profiles: PROFILES,
|
|
129
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
130
|
+
defaults: DEFAULTS,
|
|
131
|
+
errorClass: GuardOauthError,
|
|
132
|
+
errCodePrefix: "oauth",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _detectIssues(flow, opts) {
|
|
137
|
+
var issues = [];
|
|
138
|
+
if (!flow || typeof flow !== "object") {
|
|
139
|
+
return [{ kind: "bad-input", severity: "high",
|
|
140
|
+
ruleId: "oauth.bad-input",
|
|
141
|
+
snippet: "oauth flow is not an object" }];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Total-bytes cap — JSON-stringify proxy for input size.
|
|
145
|
+
try {
|
|
146
|
+
var serialized = JSON.stringify(flow);
|
|
147
|
+
if (Buffer.byteLength(serialized, "utf8") > opts.maxBytes) {
|
|
148
|
+
return [{ kind: "flow-cap", severity: "high",
|
|
149
|
+
ruleId: "oauth.flow-cap",
|
|
150
|
+
snippet: "oauth flow exceeds maxBytes " + opts.maxBytes }];
|
|
151
|
+
}
|
|
152
|
+
} catch (_e) { /* unstringifiable — flagged below */ }
|
|
153
|
+
|
|
154
|
+
// Codepoint-class threats applied to every string value at the
|
|
155
|
+
// top-level (operator nests via `flow` so this catches the canonical
|
|
156
|
+
// OAuth params).
|
|
157
|
+
var keys = Object.keys(flow);
|
|
158
|
+
for (var ki = 0; ki < keys.length; ki += 1) {
|
|
159
|
+
var v = flow[keys[ki]];
|
|
160
|
+
if (typeof v !== "string") continue;
|
|
161
|
+
if (Buffer.byteLength(v, "utf8") > opts.maxParamBytes) {
|
|
162
|
+
issues.push({
|
|
163
|
+
kind: "param-cap", severity: "high",
|
|
164
|
+
ruleId: "oauth.param-cap",
|
|
165
|
+
snippet: "oauth param `" + keys[ki] + "` exceeds maxParamBytes " +
|
|
166
|
+
opts.maxParamBytes,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
var charThreats = codepointClass.detectCharThreats(v, opts, "oauth");
|
|
171
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) {
|
|
172
|
+
issues.push(Object.assign({}, charThreats[ci], {
|
|
173
|
+
snippet: "oauth.param `" + keys[ki] + "`: " + charThreats[ci].snippet,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// PKCE.
|
|
179
|
+
if (opts.pkcePolicy !== "audit" && opts.pkcePolicy !== "allow") {
|
|
180
|
+
var hasVerifier = typeof flow.code_verifier === "string" && flow.code_verifier.length > 0;
|
|
181
|
+
var hasChallenge = typeof flow.code_challenge === "string" && flow.code_challenge.length > 0;
|
|
182
|
+
if (!hasVerifier && !hasChallenge) {
|
|
183
|
+
issues.push({
|
|
184
|
+
kind: "pkce-missing", severity: "high",
|
|
185
|
+
ruleId: "oauth.pkce-missing",
|
|
186
|
+
snippet: "neither code_verifier nor code_challenge present " +
|
|
187
|
+
"(RFC 7636 / OAuth 2.1 require PKCE for every client)",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (hasChallenge && opts.pkcePolicy === "require-s256") {
|
|
191
|
+
var method = flow.code_challenge_method || "plain";
|
|
192
|
+
if (method !== "S256") {
|
|
193
|
+
issues.push({
|
|
194
|
+
kind: "pkce-method", severity: "high",
|
|
195
|
+
ruleId: "oauth.pkce-method",
|
|
196
|
+
snippet: "code_challenge_method `" + method + "` not S256 " +
|
|
197
|
+
"(OAuth 2.1 forbids `plain` — downgrade-attack class)",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// state.
|
|
204
|
+
if (opts.statePolicy === "require") {
|
|
205
|
+
if (typeof flow.state !== "string" || flow.state.length === 0) {
|
|
206
|
+
issues.push({
|
|
207
|
+
kind: "state-missing", severity: "high",
|
|
208
|
+
ruleId: "oauth.state-missing",
|
|
209
|
+
snippet: "state parameter missing — required for CSRF defense " +
|
|
210
|
+
"(RFC 6749 §10.12)",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// redirect_uri.
|
|
216
|
+
if (typeof flow.redirect_uri === "string" &&
|
|
217
|
+
opts.redirectUriPolicy === "require-exact-allowlist") {
|
|
218
|
+
var allowlist = opts.allowedRedirectUris;
|
|
219
|
+
// When the operator hasn't configured an allowlist, the gate can't
|
|
220
|
+
// enforce exact-match; skip the check entirely. Operator-side
|
|
221
|
+
// configuration warnings live in the operator's startup audit, not
|
|
222
|
+
// in per-request issue lists.
|
|
223
|
+
if (Array.isArray(allowlist) && allowlist.length > 0 &&
|
|
224
|
+
allowlist.indexOf(flow.redirect_uri) === -1) {
|
|
225
|
+
issues.push({
|
|
226
|
+
kind: "redirect-uri-not-allowed", severity: "high",
|
|
227
|
+
ruleId: "oauth.redirect-uri-not-allowed",
|
|
228
|
+
snippet: "redirect_uri `" + flow.redirect_uri + "` not in " +
|
|
229
|
+
"operator allowlist (RFC 6749 §3.1.2 / OAuth 2.1 " +
|
|
230
|
+
"mandate exact-match)",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// response_type.
|
|
236
|
+
if (typeof flow.response_type === "string" &&
|
|
237
|
+
opts.responseTypePolicy === "require-allowlist" &&
|
|
238
|
+
Array.isArray(opts.allowedResponseTypes)) {
|
|
239
|
+
if (opts.allowedResponseTypes.indexOf(flow.response_type) === -1) {
|
|
240
|
+
issues.push({
|
|
241
|
+
kind: "response-type-not-allowed", severity: "high",
|
|
242
|
+
ruleId: "oauth.response-type-not-allowed",
|
|
243
|
+
snippet: "response_type `" + flow.response_type + "` not in " +
|
|
244
|
+
"operator allowedResponseTypes",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// scope tampering.
|
|
250
|
+
if (typeof flow.scope === "string" &&
|
|
251
|
+
opts.scopeTamperingPolicy !== "allow") {
|
|
252
|
+
var scopes = flow.scope.split(" ");
|
|
253
|
+
for (var si = 0; si < scopes.length; si += 1) {
|
|
254
|
+
var s = scopes[si];
|
|
255
|
+
if (s.length === 0) continue;
|
|
256
|
+
if (!SCOPE_TOKEN_RE.test(s)) { // allow:regex-no-length-cap — scope value bounded by maxParamBytes
|
|
257
|
+
issues.push({
|
|
258
|
+
kind: "scope-token-shape",
|
|
259
|
+
severity: opts.scopeTamperingPolicy === "reject" ? "high" : "warn",
|
|
260
|
+
ruleId: "oauth.scope-token-shape",
|
|
261
|
+
snippet: "scope token `" + s + "` violates RFC 6749 §3.3 " +
|
|
262
|
+
"scope-token charset (whitespace / control / non-printable)",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// RFC 9207 issuer on callback.
|
|
269
|
+
if (opts.issuerOnCallbackPolicy === "require" &&
|
|
270
|
+
flow._isCallback === true) {
|
|
271
|
+
if (typeof flow.iss !== "string" || flow.iss.length === 0) {
|
|
272
|
+
issues.push({
|
|
273
|
+
kind: "issuer-missing", severity: "high",
|
|
274
|
+
ruleId: "oauth.issuer-missing",
|
|
275
|
+
snippet: "iss parameter missing on callback — RFC 9207 mandates " +
|
|
276
|
+
"issuer identification to defeat IdP-mix-up attack",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// code reuse — operator supplies seenCodeStore + reportSeen() / hasSeen().
|
|
282
|
+
if (typeof flow.code === "string" &&
|
|
283
|
+
opts.codeReusePolicy !== "allow" &&
|
|
284
|
+
opts.seenCodeStore && typeof opts.seenCodeStore.hasSeen === "function") {
|
|
285
|
+
try {
|
|
286
|
+
if (opts.seenCodeStore.hasSeen(flow.code)) {
|
|
287
|
+
issues.push({
|
|
288
|
+
kind: "code-reused", severity: "critical",
|
|
289
|
+
ruleId: "oauth.code-reused",
|
|
290
|
+
snippet: "authorization code already exchanged — replay class " +
|
|
291
|
+
"(RFC 6749 §10.5)",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
} catch (_e) { /* drop-silent — operator-supplied store */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return issues;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validate(input, opts) {
|
|
301
|
+
opts = _resolveOpts(opts);
|
|
302
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
303
|
+
["maxBytes", "maxParamBytes"],
|
|
304
|
+
"guardOauth.validate", GuardOauthError, "oauth.bad-opt");
|
|
305
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function sanitize(input, opts) {
|
|
309
|
+
opts = _resolveOpts(opts);
|
|
310
|
+
// OAuth flows can't be repaired — sanitize either passes through
|
|
311
|
+
// valid input or throws.
|
|
312
|
+
var issues = _detectIssues(input, opts);
|
|
313
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
314
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
315
|
+
throw _err(issues[i].ruleId || "oauth.refused",
|
|
316
|
+
"guardOauth.sanitize: " + issues[i].snippet);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return input;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function gate(opts) {
|
|
323
|
+
opts = _resolveOpts(opts);
|
|
324
|
+
return gateContract.buildGuardGate(
|
|
325
|
+
opts.name || "guardOauth:" + (opts.profile || "default"),
|
|
326
|
+
opts,
|
|
327
|
+
async function (ctx) {
|
|
328
|
+
var flow = ctx && (ctx.oauthFlow || ctx.flow);
|
|
329
|
+
if (!flow) return { ok: true, action: "serve" };
|
|
330
|
+
var rv = validate(flow, opts);
|
|
331
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
332
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
333
|
+
return i.severity === "critical";
|
|
334
|
+
});
|
|
335
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
336
|
+
return i.severity === "high";
|
|
337
|
+
});
|
|
338
|
+
if (!hasCritical && !hasHigh) {
|
|
339
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
340
|
+
}
|
|
341
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
346
|
+
|
|
347
|
+
function compliancePosture(name) {
|
|
348
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
349
|
+
_err, "oauth");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
var _oauthRulePacks = gateContract.makeRulePackLoader(GuardOauthError, "oauth");
|
|
353
|
+
var loadRulePack = _oauthRulePacks.load;
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
// ---- guard-* family registry exports ----
|
|
357
|
+
NAME: "oauth",
|
|
358
|
+
KIND: "oauth-flow",
|
|
359
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
360
|
+
kind: "oauth-flow",
|
|
361
|
+
benignBytes: Buffer.from(JSON.stringify({
|
|
362
|
+
response_type: "code",
|
|
363
|
+
redirect_uri: "https://app.example.com/callback",
|
|
364
|
+
state: "csrf-rand-1",
|
|
365
|
+
scope: "openid profile",
|
|
366
|
+
code_challenge: "abc123def456ghi789jkl012mno345pqr678", // allow:raw-byte-literal — base64url-shaped fixture
|
|
367
|
+
code_challenge_method: "S256",
|
|
368
|
+
}), "utf8"),
|
|
369
|
+
hostileBytes: Buffer.from(JSON.stringify({
|
|
370
|
+
response_type: "code",
|
|
371
|
+
redirect_uri: "https://attacker.example/callback",
|
|
372
|
+
// state missing — CSRF class
|
|
373
|
+
scope: "openid",
|
|
374
|
+
}), "utf8"),
|
|
375
|
+
benignOauthFlow: {
|
|
376
|
+
response_type: "code",
|
|
377
|
+
redirect_uri: "https://app.example.com/callback",
|
|
378
|
+
state: "csrf-rand-1",
|
|
379
|
+
scope: "openid profile",
|
|
380
|
+
code_challenge: "abc123def456ghi789jkl012mno345pqr678", // allow:raw-byte-literal — base64url-shaped fixture
|
|
381
|
+
code_challenge_method: "S256",
|
|
382
|
+
},
|
|
383
|
+
hostileOauthFlow: {
|
|
384
|
+
response_type: "code",
|
|
385
|
+
redirect_uri: "https://attacker.example/callback",
|
|
386
|
+
// state missing → state-missing refuse
|
|
387
|
+
scope: "openid",
|
|
388
|
+
},
|
|
389
|
+
}),
|
|
390
|
+
// ---- primitive surface ----
|
|
391
|
+
validate: validate,
|
|
392
|
+
sanitize: sanitize,
|
|
393
|
+
gate: gate,
|
|
394
|
+
buildProfile: buildProfile,
|
|
395
|
+
compliancePosture: compliancePosture,
|
|
396
|
+
loadRulePack: loadRulePack,
|
|
397
|
+
PROFILES: PROFILES,
|
|
398
|
+
DEFAULTS: DEFAULTS,
|
|
399
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
400
|
+
GuardOauthError: GuardOauthError,
|
|
401
|
+
};
|
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:dab55eee-319c-4677-a33f-e9950e36d7d8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-05T22:
|
|
8
|
+
"timestamp": "2026-05-05T22:37:56.695Z",
|
|
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.51",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.51",
|
|
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.51",
|
|
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.51",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|