@blamejs/core 0.7.47 → 0.7.48
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 +2 -0
- package/lib/cookies.js +117 -0
- package/lib/middleware/cookies.js +98 -0
- package/lib/middleware/index.js +2 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
11
13
|
- **0.7.47** (2026-05-05) — `b.guardMime` — RFC 6838 media-type identifier-safety primitive (KIND="identifier"). Validates user-supplied media type strings destined for Accept-shape comparison, content-type allowlists, and dispatch routing. Threat catalog: shape malformation (missing `/`, bad type/subtype tokens against RFC 6838 §4.2 restricted-name grammar); parameter validation against the RFC 7231 §3.1.1.1 tchar token grammar (token-only or quoted-string per RFC 7230 §3.2.6); wildcard (`type/subtype` with `*`) outside Accept context refuse; vendor tree (`vnd.*`), personal tree (`prs.*`), and unregistered (`x.*` / `x-*`) namespace audit so operators audit those slots; risky-type refuse list covering executable + script-host content types (`application/x-msdownload`, `application/x-bat`, `application/x-msdos-program`, `application/x-sh`, `application/x-csh`, `application/x-perl`, `application/x-python`, `application/javascript`, `application/x-javascript`, `text/javascript`, `text/x-javascript`, `application/x-shockwave-flash`, `application/x-msi`); BIDI / zero-width / control / null-byte universal refuse. `sanitize` lowercases type/subtype while preserving parameter case (multipart boundary tokens etc. are case-significant). Profiles: `strict` (refuse wildcard + risky-type, audit trees + parameters), `balanced` (audit most things, allow vendor tree), `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.
|
|
12
14
|
|
|
13
15
|
- **0.7.46** (2026-05-05) — `b.guardTime` — RFC 3339 / ISO 8601 datetime identifier-safety primitive (KIND="identifier"). Validates user-supplied datetime strings destined for audit timestamps, scheduling, retention windows, query ranges, and cross-system event correlation. Threat catalog: shape malformation against the RFC 3339 §5.6 grammar; year-window overflow (default `[1970, 9999]`); naive datetime (no offset) refuse; non-UTC offset policy (strict requires `Z` / `+00:00`); leap-second `60` field policy (RFC 3339 §5.6 valid but parser-panic prone); excessive fractional precision cap (default 9 digits / nanoseconds); date-only and time-only refuse for full-datetime contexts; structural range violations (month / day-in-month / hour / minute / second); BIDI / zero-width / control / null-byte universal refuse. `sanitize` normalizes a space date/time separator to `T` and uppercases the trailing `z` UTC marker. Profiles: `strict` (refuse all of the above), `balanced` (refuse naive; audit non-UTC + leap + fractional + date/time-only), `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.
|
package/lib/cookies.js
CHANGED
|
@@ -342,9 +342,126 @@ function create(opts) {
|
|
|
342
342
|
};
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// parseSafe — threat-detecting inbound-cookie parser. Returns
|
|
346
|
+
// { jar, issues } where every detected anomaly surfaces as an issue
|
|
347
|
+
// instead of being silently dropped (as the lenient parse() does).
|
|
348
|
+
//
|
|
349
|
+
// Threat catalog applied to the inbound Cookie header:
|
|
350
|
+
// - Oversized header — total bytes exceed maxHeaderBytes (default 8 KiB).
|
|
351
|
+
// - Oversized pair — name + value exceeds NAME_LENGTH + VALUE_LENGTH cap.
|
|
352
|
+
// - Duplicate cookie name — RFC 6265 last-write-wins is the browser
|
|
353
|
+
// behavior, but two pairs with the same name in one Cookie header
|
|
354
|
+
// usually indicates cookie-tossing (attacker-set parent-domain
|
|
355
|
+
// cookie shadowing the legitimate one).
|
|
356
|
+
// - Malformed pair — missing `=` or empty name.
|
|
357
|
+
// - Forbidden chars in raw header — CR / LF / NUL injected through
|
|
358
|
+
// a downstream proxy.
|
|
359
|
+
// - Empty / non-string input — operator-misuse signal.
|
|
360
|
+
//
|
|
361
|
+
// Issue shape: { kind, severity: "high"|"warn", snippet, name? }.
|
|
362
|
+
//
|
|
363
|
+
// Operators wire it through `b.middleware.cookies` (the convenience
|
|
364
|
+
// middleware below) or call directly when they want the issues list
|
|
365
|
+
// without imposing a request lifecycle.
|
|
366
|
+
function parseSafe(cookieHeader, opts) {
|
|
367
|
+
opts = opts || {};
|
|
368
|
+
var maxHeaderBytes = opts.maxHeaderBytes || C.BYTES.kib(8);
|
|
369
|
+
var maxNameBytes = opts.maxNameBytes || MAX_NAME_LENGTH;
|
|
370
|
+
var maxValueBytes = opts.maxValueBytes || MAX_VALUE_LENGTH;
|
|
371
|
+
|
|
372
|
+
if (typeof cookieHeader !== "string") {
|
|
373
|
+
return {
|
|
374
|
+
jar: {},
|
|
375
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
376
|
+
snippet: "cookie header is not a string" }],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (cookieHeader.length === 0) return { jar: {}, issues: [] };
|
|
380
|
+
|
|
381
|
+
var issues = [];
|
|
382
|
+
var jar = Object.create(null);
|
|
383
|
+
var seen = Object.create(null);
|
|
384
|
+
|
|
385
|
+
if (Buffer.byteLength(cookieHeader, "utf8") > maxHeaderBytes) {
|
|
386
|
+
issues.push({
|
|
387
|
+
kind: "header-cap", severity: "high",
|
|
388
|
+
snippet: "Cookie header " + cookieHeader.length + " bytes exceeds " +
|
|
389
|
+
"maxHeaderBytes " + maxHeaderBytes,
|
|
390
|
+
});
|
|
391
|
+
return { jar: jar, issues: issues };
|
|
392
|
+
}
|
|
393
|
+
for (var hi = 0; hi < cookieHeader.length; hi += 1) {
|
|
394
|
+
var ch = cookieHeader.charCodeAt(hi);
|
|
395
|
+
if (ch === 0x0D || ch === 0x0A || ch === 0x00) { // allow:raw-byte-literal — CR / LF / NUL forbidden in cookie header
|
|
396
|
+
issues.push({
|
|
397
|
+
kind: "header-control-byte", severity: "high",
|
|
398
|
+
snippet: "Cookie header contains CR / LF / NUL — proxy-side " +
|
|
399
|
+
"header injection vector",
|
|
400
|
+
});
|
|
401
|
+
return { jar: jar, issues: issues };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
var pairs = cookieHeader.split(/;\s*/);
|
|
406
|
+
for (var i = 0; i < pairs.length; i += 1) {
|
|
407
|
+
var pair = pairs[i];
|
|
408
|
+
if (!pair) continue;
|
|
409
|
+
var eq = pair.indexOf("=");
|
|
410
|
+
if (eq < 0) {
|
|
411
|
+
issues.push({
|
|
412
|
+
kind: "pair-malformed", severity: "warn",
|
|
413
|
+
snippet: "cookie pair " + JSON.stringify(pair) + " missing `=`",
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
var k = pair.slice(0, eq).trim();
|
|
418
|
+
if (!k) {
|
|
419
|
+
issues.push({
|
|
420
|
+
kind: "pair-empty-name", severity: "warn",
|
|
421
|
+
snippet: "cookie pair has empty name",
|
|
422
|
+
});
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
var v = pair.slice(eq + 1).trim();
|
|
426
|
+
if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
|
|
427
|
+
v = v.slice(1, -1);
|
|
428
|
+
}
|
|
429
|
+
try { v = decodeURIComponent(v); }
|
|
430
|
+
catch (_e) { /* malformed encoding — keep raw */ }
|
|
431
|
+
|
|
432
|
+
if (Buffer.byteLength(k, "utf8") > maxNameBytes) {
|
|
433
|
+
issues.push({
|
|
434
|
+
kind: "name-cap", severity: "high", name: k,
|
|
435
|
+
snippet: "cookie name exceeds maxNameBytes " + maxNameBytes,
|
|
436
|
+
});
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (Buffer.byteLength(v, "utf8") > maxValueBytes) {
|
|
440
|
+
issues.push({
|
|
441
|
+
kind: "value-cap", severity: "high", name: k,
|
|
442
|
+
snippet: "cookie `" + k + "` value exceeds maxValueBytes " +
|
|
443
|
+
maxValueBytes,
|
|
444
|
+
});
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (seen[k]) {
|
|
448
|
+
issues.push({
|
|
449
|
+
kind: "duplicate-name", severity: "high", name: k,
|
|
450
|
+
snippet: "cookie name `" + k + "` appears more than once — " +
|
|
451
|
+
"browser last-write-wins; cookie-tossing class " +
|
|
452
|
+
"(parent-domain cookie shadowing the legitimate one)",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
seen[k] = true;
|
|
456
|
+
jar[k] = v;
|
|
457
|
+
}
|
|
458
|
+
return { jar: jar, issues: issues };
|
|
459
|
+
}
|
|
460
|
+
|
|
345
461
|
module.exports = {
|
|
346
462
|
create: create,
|
|
347
463
|
parse: parse,
|
|
464
|
+
parseSafe: parseSafe,
|
|
348
465
|
serialize: serialize,
|
|
349
466
|
CookieError: CookieError,
|
|
350
467
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.middleware.cookies — inbound cookie-header threat detection.
|
|
4
|
+
*
|
|
5
|
+
* Sits in the request lifecycle and runs `b.cookies.parseSafe` against
|
|
6
|
+
* the inbound `Cookie` header. Threat detection always-on; the gate
|
|
7
|
+
* either refuses, audits, or logs the detected anomalies based on the
|
|
8
|
+
* operator's `mode`.
|
|
9
|
+
*
|
|
10
|
+
* Threats surfaced (see lib/cookies.js parseSafe):
|
|
11
|
+
* - header-cap — Cookie header exceeds maxHeaderBytes
|
|
12
|
+
* - header-control-byte — CR / LF / NUL injected through proxy
|
|
13
|
+
* - pair-malformed — pair missing `=`
|
|
14
|
+
* - pair-empty-name — empty name
|
|
15
|
+
* - name-cap — cookie name exceeds maxNameBytes
|
|
16
|
+
* - value-cap — cookie value exceeds maxValueBytes
|
|
17
|
+
* - duplicate-name — name appears >1 time (cookie-tossing class)
|
|
18
|
+
*
|
|
19
|
+
* Side effects:
|
|
20
|
+
* - req.cookieJar — populated with the parsed jar (overwriteable)
|
|
21
|
+
* - audit emission — one row per detected high-severity issue
|
|
22
|
+
* - response — refused requests get HTTP 400 + JSON body
|
|
23
|
+
*
|
|
24
|
+
* var middleware = b.middleware.cookies({
|
|
25
|
+
* mode: "enforce", // "enforce" | "audit-only" | "log-only"
|
|
26
|
+
* audit: b.audit,
|
|
27
|
+
* maxHeaderBytes: 8 * 1024,
|
|
28
|
+
* refuseOnHigh: true, // 400 if any high-severity issue
|
|
29
|
+
* });
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
var cookies = require("../cookies");
|
|
33
|
+
var lazyRequire = require("../lazy-require");
|
|
34
|
+
|
|
35
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
36
|
+
void observability;
|
|
37
|
+
|
|
38
|
+
function _emitAudit(audit, action, outcome, metadata) {
|
|
39
|
+
if (!audit || typeof audit.safeEmit !== "function") return;
|
|
40
|
+
try {
|
|
41
|
+
audit.safeEmit({
|
|
42
|
+
action: action,
|
|
43
|
+
actor: metadata.actor || { kind: "framework", id: "middleware/cookies" },
|
|
44
|
+
outcome: outcome,
|
|
45
|
+
metadata: metadata,
|
|
46
|
+
});
|
|
47
|
+
} catch (_e) { /* drop-silent — observability sink */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function create(opts) {
|
|
51
|
+
opts = opts || {};
|
|
52
|
+
var mode = opts.mode || "enforce";
|
|
53
|
+
var refuseOnHigh = opts.refuseOnHigh !== false && mode === "enforce";
|
|
54
|
+
var maxHeaderBytes = opts.maxHeaderBytes;
|
|
55
|
+
var maxNameBytes = opts.maxNameBytes;
|
|
56
|
+
var maxValueBytes = opts.maxValueBytes;
|
|
57
|
+
var audit = opts.audit || null;
|
|
58
|
+
|
|
59
|
+
return function cookiesMiddleware(req, res, next) {
|
|
60
|
+
var header = req && req.headers ? req.headers.cookie : "";
|
|
61
|
+
var rv = cookies.parseSafe(header || "", {
|
|
62
|
+
maxHeaderBytes: maxHeaderBytes,
|
|
63
|
+
maxNameBytes: maxNameBytes,
|
|
64
|
+
maxValueBytes: maxValueBytes,
|
|
65
|
+
});
|
|
66
|
+
req.cookieJar = rv.jar;
|
|
67
|
+
|
|
68
|
+
if (rv.issues.length === 0) return next();
|
|
69
|
+
|
|
70
|
+
var hasHigh = false;
|
|
71
|
+
for (var i = 0; i < rv.issues.length; i += 1) {
|
|
72
|
+
var iss = rv.issues[i];
|
|
73
|
+
if (iss.severity === "high") hasHigh = true;
|
|
74
|
+
_emitAudit(audit, "middleware.cookies.threat-detected",
|
|
75
|
+
iss.severity === "high" ? "blocked" : "audit", {
|
|
76
|
+
kind: iss.kind,
|
|
77
|
+
name: iss.name || null,
|
|
78
|
+
snippet: iss.snippet,
|
|
79
|
+
mode: mode,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (hasHigh && refuseOnHigh) {
|
|
84
|
+
res.statusCode = 400;
|
|
85
|
+
res.setHeader("Content-Type", "application/json");
|
|
86
|
+
res.end(JSON.stringify({
|
|
87
|
+
error: "cookie-threat-detected",
|
|
88
|
+
issues: rv.issues.map(function (i) {
|
|
89
|
+
return { kind: i.kind, severity: i.severity };
|
|
90
|
+
}),
|
|
91
|
+
}));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return next();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { create: create };
|
package/lib/middleware/index.js
CHANGED
|
@@ -22,6 +22,7 @@ var bearerAuth = require("./bearer-auth");
|
|
|
22
22
|
var bodyParser = require("./body-parser");
|
|
23
23
|
var botGuard = require("./bot-guard");
|
|
24
24
|
var compression = require("./compression");
|
|
25
|
+
var cookies = require("./cookies");
|
|
25
26
|
var cors = require("./cors");
|
|
26
27
|
var cspNonce = require("./csp-nonce");
|
|
27
28
|
var csrfProtect = require("./csrf-protect");
|
|
@@ -52,6 +53,7 @@ module.exports = {
|
|
|
52
53
|
bodyParser: bodyParser.create,
|
|
53
54
|
health: health.create,
|
|
54
55
|
compression: compression.create,
|
|
56
|
+
cookies: cookies.create,
|
|
55
57
|
cspNonce: cspNonce.create,
|
|
56
58
|
sse: sse.create,
|
|
57
59
|
requestLog: requestLog.create,
|
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:3da7281a-182d-463b-a5eb-166309f6fa76",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-05T21:
|
|
8
|
+
"timestamp": "2026-05-05T21:55:22.735Z",
|
|
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.48",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.48",
|
|
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.48",
|
|
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.48",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|