@blamejs/core 0.7.48 → 0.7.49
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/middleware/headers.js +206 -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.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
|
+
|
|
11
13
|
- **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
14
|
|
|
13
15
|
- **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.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.middleware.headers — inbound HTTP header threat detection.
|
|
4
|
+
*
|
|
5
|
+
* Sits at the top of the request lifecycle. Validates the inbound
|
|
6
|
+
* headers against the RFC 9110 §5.1 token grammar and surfaces threat
|
|
7
|
+
* shapes: CRLF injection, CL+TE request smuggling (RFC 9112 §6.1),
|
|
8
|
+
* oversized header / count, deprecated trust-header patterns.
|
|
9
|
+
*
|
|
10
|
+
* Threat catalog:
|
|
11
|
+
* - header-name-shape — header name not a valid RFC 9110 token.
|
|
12
|
+
* - header-value-control-byte — CR / LF / NUL inside a header value
|
|
13
|
+
* (header-injection defense in depth on top of Node's rejection).
|
|
14
|
+
* - header-count-cap — > maxHeaderCount headers (default 100).
|
|
15
|
+
* - header-value-cap — single value > maxValueBytes (default 8 KiB).
|
|
16
|
+
* - smuggling-cl-te — Content-Length AND Transfer-Encoding both
|
|
17
|
+
* present (RFC 9112 §6.1 — CL.TE / TE.CL smuggling shape).
|
|
18
|
+
* - smuggling-cl-multi — multiple Content-Length values (proxy-
|
|
19
|
+
* desync class).
|
|
20
|
+
* - smuggling-te-multi — multiple Transfer-Encoding values.
|
|
21
|
+
* - deprecated-trust-header — X-Forwarded-For / X-Forwarded-Proto /
|
|
22
|
+
* X-Forwarded-Host present without operator-supplied trustProxy
|
|
23
|
+
* opt — the framework warns once that the operator should adopt
|
|
24
|
+
* RFC 7239 `Forwarded` or explicit trustProxy.
|
|
25
|
+
*
|
|
26
|
+
* var middleware = b.middleware.headers({
|
|
27
|
+
* mode: "enforce", // "enforce" | "audit-only" | "log-only"
|
|
28
|
+
* audit: b.audit,
|
|
29
|
+
* maxHeaderCount: 100,
|
|
30
|
+
* maxValueBytes: 8 * 1024,
|
|
31
|
+
* trustProxy: false, // whether X-Forwarded-* is allowed
|
|
32
|
+
* refuseOnHigh: true,
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var lazyRequire = require("../lazy-require");
|
|
37
|
+
|
|
38
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
39
|
+
void observability;
|
|
40
|
+
|
|
41
|
+
// RFC 9110 §5.1 token grammar — tchar set.
|
|
42
|
+
var TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
43
|
+
|
|
44
|
+
var DEPRECATED_TRUST_HEADERS = Object.freeze([
|
|
45
|
+
"x-forwarded-for",
|
|
46
|
+
"x-forwarded-proto",
|
|
47
|
+
"x-forwarded-host",
|
|
48
|
+
"x-forwarded-port",
|
|
49
|
+
"x-real-ip",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function _emitAudit(audit, action, outcome, metadata) {
|
|
53
|
+
if (!audit || typeof audit.safeEmit !== "function") return;
|
|
54
|
+
try {
|
|
55
|
+
audit.safeEmit({
|
|
56
|
+
action: action,
|
|
57
|
+
actor: metadata.actor || { kind: "framework", id: "middleware/headers" },
|
|
58
|
+
outcome: outcome,
|
|
59
|
+
metadata: metadata,
|
|
60
|
+
});
|
|
61
|
+
} catch (_e) { /* drop-silent — observability sink */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _detectIssues(headers, opts) {
|
|
65
|
+
var issues = [];
|
|
66
|
+
if (!headers || typeof headers !== "object") return issues;
|
|
67
|
+
|
|
68
|
+
var names = Object.keys(headers);
|
|
69
|
+
|
|
70
|
+
if (names.length > opts.maxHeaderCount) {
|
|
71
|
+
issues.push({
|
|
72
|
+
kind: "header-count-cap", severity: "high",
|
|
73
|
+
snippet: "request has " + names.length + " headers, exceeds " +
|
|
74
|
+
"maxHeaderCount " + opts.maxHeaderCount,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (var i = 0; i < names.length; i += 1) {
|
|
79
|
+
var name = names[i];
|
|
80
|
+
var value = headers[name];
|
|
81
|
+
|
|
82
|
+
// Header name shape — Node lowercases names; the original tchar
|
|
83
|
+
// grammar covers a-z / 0-9 / `!#$%&'*+-.^_`|~`.
|
|
84
|
+
if (!TOKEN_RE.test(name)) { // allow:regex-no-length-cap — Node already caps header name length at 8190 chars by default (HTTP/1.1 line cap)
|
|
85
|
+
issues.push({
|
|
86
|
+
kind: "header-name-shape", severity: "high",
|
|
87
|
+
snippet: "header name `" + name + "` is not a valid RFC 9110 " +
|
|
88
|
+
"§5.1 token",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var valueArr = Array.isArray(value) ? value : [value];
|
|
93
|
+
for (var vi = 0; vi < valueArr.length; vi += 1) {
|
|
94
|
+
var v = valueArr[vi];
|
|
95
|
+
if (typeof v !== "string") continue;
|
|
96
|
+
if (Buffer.byteLength(v, "utf8") > opts.maxValueBytes) {
|
|
97
|
+
issues.push({
|
|
98
|
+
kind: "header-value-cap", severity: "high", header: name,
|
|
99
|
+
snippet: "header `" + name + "` value " + v.length +
|
|
100
|
+
" bytes exceeds maxValueBytes " + opts.maxValueBytes,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (var ci = 0; ci < v.length; ci += 1) {
|
|
105
|
+
var cc = v.charCodeAt(ci);
|
|
106
|
+
if (cc === 0x0D || cc === 0x0A || cc === 0x00) { // allow:raw-byte-literal — CR / LF / NUL forbidden in header value
|
|
107
|
+
issues.push({
|
|
108
|
+
kind: "header-value-control-byte", severity: "high", header: name,
|
|
109
|
+
snippet: "header `" + name + "` value contains CR / LF / NUL " +
|
|
110
|
+
"— header-injection defense in depth",
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Smuggling shapes (RFC 9112 §6.1).
|
|
119
|
+
var clRaw = headers["content-length"];
|
|
120
|
+
var teRaw = headers["transfer-encoding"];
|
|
121
|
+
if (clRaw !== undefined && teRaw !== undefined) {
|
|
122
|
+
issues.push({
|
|
123
|
+
kind: "smuggling-cl-te", severity: "high",
|
|
124
|
+
snippet: "both Content-Length and Transfer-Encoding present " +
|
|
125
|
+
"(RFC 9112 §6.1 — CL.TE / TE.CL request-smuggling vector)",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(clRaw) && clRaw.length > 1) {
|
|
129
|
+
issues.push({
|
|
130
|
+
kind: "smuggling-cl-multi", severity: "high",
|
|
131
|
+
snippet: "multiple Content-Length values — proxy-desync " +
|
|
132
|
+
"request-smuggling vector",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (Array.isArray(teRaw) && teRaw.length > 1) {
|
|
136
|
+
issues.push({
|
|
137
|
+
kind: "smuggling-te-multi", severity: "high",
|
|
138
|
+
snippet: "multiple Transfer-Encoding values — proxy-desync " +
|
|
139
|
+
"request-smuggling vector",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Deprecated trust-header pattern.
|
|
144
|
+
if (!opts.trustProxy) {
|
|
145
|
+
for (var di = 0; di < DEPRECATED_TRUST_HEADERS.length; di += 1) {
|
|
146
|
+
var h = DEPRECATED_TRUST_HEADERS[di];
|
|
147
|
+
if (headers[h] !== undefined) {
|
|
148
|
+
issues.push({
|
|
149
|
+
kind: "deprecated-trust-header", severity: "warn", header: h,
|
|
150
|
+
snippet: "request carries `" + h + "` but trustProxy is " +
|
|
151
|
+
"false — adopt RFC 7239 `Forwarded` or set " +
|
|
152
|
+
"trustProxy explicitly",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function create(opts) {
|
|
162
|
+
opts = opts || {};
|
|
163
|
+
var mode = opts.mode || "enforce";
|
|
164
|
+
var refuseOnHigh = opts.refuseOnHigh !== false && mode === "enforce";
|
|
165
|
+
var audit = opts.audit || null;
|
|
166
|
+
var resolved = {
|
|
167
|
+
maxHeaderCount: opts.maxHeaderCount || 100, // allow:raw-byte-literal — header count ceiling
|
|
168
|
+
maxValueBytes: opts.maxValueBytes || 8 * 1024, // allow:raw-byte-literal — header value cap (8 KiB)
|
|
169
|
+
trustProxy: !!opts.trustProxy,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return function headersMiddleware(req, res, next) {
|
|
173
|
+
var headers = req && req.headers ? req.headers : {};
|
|
174
|
+
var issues = _detectIssues(headers, resolved);
|
|
175
|
+
if (issues.length === 0) return next();
|
|
176
|
+
|
|
177
|
+
var hasHigh = false;
|
|
178
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
179
|
+
var iss = issues[i];
|
|
180
|
+
if (iss.severity === "high") hasHigh = true;
|
|
181
|
+
_emitAudit(audit, "middleware.headers.threat-detected",
|
|
182
|
+
iss.severity === "high" ? "blocked" : "audit", {
|
|
183
|
+
kind: iss.kind,
|
|
184
|
+
header: iss.header || null,
|
|
185
|
+
snippet: iss.snippet,
|
|
186
|
+
mode: mode,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (hasHigh && refuseOnHigh) {
|
|
191
|
+
res.statusCode = 400;
|
|
192
|
+
res.setHeader("Content-Type", "application/json");
|
|
193
|
+
res.end(JSON.stringify({
|
|
194
|
+
error: "header-threat-detected",
|
|
195
|
+
issues: issues.filter(function (i) { return i.severity === "high"; })
|
|
196
|
+
.map(function (i) {
|
|
197
|
+
return { kind: i.kind, header: i.header || null };
|
|
198
|
+
}),
|
|
199
|
+
}));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
return next();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { create: create };
|
package/lib/middleware/index.js
CHANGED
|
@@ -29,6 +29,7 @@ var csrfProtect = require("./csrf-protect");
|
|
|
29
29
|
var dbRoleFor = require("./db-role-for");
|
|
30
30
|
var errorHandler = require("./error-handler");
|
|
31
31
|
var fetchMetadata = require("./fetch-metadata");
|
|
32
|
+
var headers = require("./headers");
|
|
32
33
|
var health = require("./health");
|
|
33
34
|
var networkAllowlist = require("./network-allowlist");
|
|
34
35
|
var rateLimit = require("./rate-limit");
|
|
@@ -50,6 +51,7 @@ module.exports = {
|
|
|
50
51
|
requireAuth: requireAuth.create,
|
|
51
52
|
csrfProtect: csrfProtect.create,
|
|
52
53
|
fetchMetadata: fetchMetadata.create,
|
|
54
|
+
headers: headers.create,
|
|
53
55
|
bodyParser: bodyParser.create,
|
|
54
56
|
health: health.create,
|
|
55
57
|
compression: compression.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:edb0b995-1067-4678-bbbc-7072a4712f7a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-05T22:04:11.156Z",
|
|
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.49",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.49",
|
|
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.49",
|
|
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.49",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|