@blamejs/core 0.14.17 → 0.14.18
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/README.md +2 -2
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +88 -8
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +22 -7
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +7 -4
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/safe-buffer.js +55 -0
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -22,9 +22,42 @@
|
|
|
22
22
|
var nodeCrypto = require("node:crypto");
|
|
23
23
|
var validateOpts = require("../validate-opts");
|
|
24
24
|
var lazyRequire = require("../lazy-require");
|
|
25
|
+
var safeUrl = require("../safe-url");
|
|
25
26
|
var { defineClass } = require("../framework-error");
|
|
26
27
|
var AsyncApiError = defineClass("AsyncApiError", { alwaysPermanent: true });
|
|
27
28
|
|
|
29
|
+
// Validate an operator-supplied accessControl.allowOrigin and return the
|
|
30
|
+
// canonical `scheme://host[:port]` string for the Access-Control-Allow-
|
|
31
|
+
// Origin response header. CORS (Fetch Standard §3.2.1) requires a single
|
|
32
|
+
// concrete origin with no path / query / fragment; the empty-string and
|
|
33
|
+
// "*" wildcard forms are spelled separately ("same-origin" / "public").
|
|
34
|
+
// Parsing through safeUrl rejects header-injection bytes (CR/LF) and
|
|
35
|
+
// userinfo, and confirms the value is a real http(s) origin. Throws so
|
|
36
|
+
// the operator catches a typo'd allowOrigin at boot.
|
|
37
|
+
function _canonicalAllowOrigin(value, label) {
|
|
38
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
39
|
+
throw new AsyncApiError("asyncapi/bad-access-control",
|
|
40
|
+
label + ": accessControl.allowOrigin must be a non-empty origin string " +
|
|
41
|
+
"(e.g. \"https://docs.example.com\")");
|
|
42
|
+
}
|
|
43
|
+
var parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new AsyncApiError("asyncapi/bad-access-control",
|
|
48
|
+
label + ": accessControl.allowOrigin '" + value + "' is not a valid " +
|
|
49
|
+
"http(s) origin: " + ((e && e.message) || String(e)));
|
|
50
|
+
}
|
|
51
|
+
var path = parsed.pathname || "";
|
|
52
|
+
if ((path !== "" && path !== "/") || parsed.search || parsed.hash) {
|
|
53
|
+
throw new AsyncApiError("asyncapi/bad-access-control",
|
|
54
|
+
label + ": accessControl.allowOrigin must be a bare origin " +
|
|
55
|
+
"(scheme://host[:port]) with no path / query / fragment; got '" + value + "'");
|
|
56
|
+
}
|
|
57
|
+
var port = parsed.port;
|
|
58
|
+
return parsed.protocol + "//" + parsed.hostname.toLowerCase() + (port ? ":" + port : "");
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
var openapiYaml = lazyRequire(function () { return require("../openapi-yaml"); });
|
|
29
62
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
30
63
|
|
|
@@ -38,8 +71,12 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
38
71
|
* configurable JSON + YAML mount points. Matches `openapiServe`
|
|
39
72
|
* behaviour: GET/HEAD only, SHA3-512 ETag with conditional 304,
|
|
40
73
|
* operator-controlled CORS gate, falls through on unmatched paths
|
|
41
|
-
* or methods.
|
|
42
|
-
*
|
|
74
|
+
* or methods. `accessControl: "public"` (default) emits
|
|
75
|
+
* `Access-Control-Allow-Origin: *`; `same-origin` omits the header;
|
|
76
|
+
* `{ allowOrigin: "https://docs.example.com" }` echoes one validated
|
|
77
|
+
* origin with `Vary: Origin`. Use to publish channel + operation +
|
|
78
|
+
* message + schema specs for event-driven APIs (Kafka, AMQP, MQTT,
|
|
79
|
+
* WebSocket).
|
|
43
80
|
*
|
|
44
81
|
* @opts
|
|
45
82
|
* {
|
|
@@ -79,6 +116,17 @@ function create(opts) {
|
|
|
79
116
|
var cacheControl = (typeof opts.cacheControl === "string" && opts.cacheControl.length > 0)
|
|
80
117
|
? opts.cacheControl : "public, max-age=300";
|
|
81
118
|
var accessControl = opts.accessControl || "public";
|
|
119
|
+
// Resolve the Access-Control-Allow-Origin value once at config time.
|
|
120
|
+
// "public" → "*"; an { allowOrigin } object → the canonical origin
|
|
121
|
+
// (validated, throws on a bad value); "same-origin" / anything else →
|
|
122
|
+
// null (no CORS header emitted).
|
|
123
|
+
var allowOriginHeader = null;
|
|
124
|
+
if (accessControl === "public") {
|
|
125
|
+
allowOriginHeader = "*";
|
|
126
|
+
} else if (accessControl && typeof accessControl === "object" &&
|
|
127
|
+
typeof accessControl.allowOrigin === "string") {
|
|
128
|
+
allowOriginHeader = _canonicalAllowOrigin(accessControl.allowOrigin, "asyncapiServe");
|
|
129
|
+
}
|
|
82
130
|
var auditOn = opts.audit !== false;
|
|
83
131
|
|
|
84
132
|
if (typeof pathJson !== "string" || pathJson.charAt(0) !== "/") {
|
|
@@ -117,8 +165,12 @@ function create(opts) {
|
|
|
117
165
|
"Cache-Control": cacheControl,
|
|
118
166
|
"ETag": etag,
|
|
119
167
|
};
|
|
120
|
-
if (
|
|
121
|
-
headers["Access-Control-Allow-Origin"] =
|
|
168
|
+
if (allowOriginHeader !== null) {
|
|
169
|
+
headers["Access-Control-Allow-Origin"] = allowOriginHeader;
|
|
170
|
+
// A specific (non-"*") origin makes the response vary by Origin;
|
|
171
|
+
// advertise it so shared caches don't serve one operator's allowed
|
|
172
|
+
// origin to another's request (Fetch Standard §3.2.1).
|
|
173
|
+
if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
|
|
122
174
|
}
|
|
123
175
|
res.writeHead(200, headers); // HTTP 200
|
|
124
176
|
res.end(body);
|
|
@@ -33,18 +33,27 @@
|
|
|
33
33
|
*
|
|
34
34
|
* Options:
|
|
35
35
|
* {
|
|
36
|
-
* cookieName:
|
|
37
|
-
* tokenFrom:
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* cookieName: 'blamejs_session' (cookie name to read)
|
|
37
|
+
* tokenFrom: 'both' | 'cookie' | 'header' (default 'both')
|
|
38
|
+
* bearerScheme: 'Bearer' (Authorization scheme token; RFC 6750 §2.1)
|
|
39
|
+
* tokenExtractor: (req) => token | null (overrides header extraction entirely)
|
|
40
|
+
* sealed: false (use cookies.readSealed)
|
|
41
|
+
* vault: b.vault (required when sealed)
|
|
42
|
+
* userLoader: async (verifiedSession) => user (REQUIRED)
|
|
43
|
+
* audit: true (emit user-load audits)
|
|
42
44
|
* }
|
|
45
|
+
*
|
|
46
|
+
* The Authorization-header path matches the `Bearer` scheme by default
|
|
47
|
+
* (RFC 6750 §2.1). Operators behind a gateway that issues a different
|
|
48
|
+
* scheme (e.g. `Token`, `DPoP` per RFC 9449) set bearerScheme, or pass
|
|
49
|
+
* tokenExtractor(req) to read the credential from anywhere on the
|
|
50
|
+
* request. The scheme match is case-insensitive (RFC 9110 §11.1).
|
|
43
51
|
*/
|
|
44
52
|
var lazyRequire = require("../lazy-require");
|
|
45
53
|
var cookies = require("../cookies");
|
|
46
54
|
var requestHelpers = require("../request-helpers");
|
|
47
55
|
var validateOpts = require("../validate-opts");
|
|
56
|
+
var codepointClass = require("../codepoint-class");
|
|
48
57
|
var session = lazyRequire(function () { return require("../session"); });
|
|
49
58
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
50
59
|
|
|
@@ -56,10 +65,17 @@ function _readCookie(cookieHeader, name) {
|
|
|
56
65
|
return Object.prototype.hasOwnProperty.call(jar, name) ? jar[name] : null;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
// Read the credential after an Authorization scheme token. Default scheme
|
|
69
|
+
// is "Bearer" (RFC 6750 §2.1); operators fronted by a gateway that mints
|
|
70
|
+
// "Token", "DPoP" (RFC 9449), or a custom scheme pass that name so the
|
|
71
|
+
// header is consumed instead of silently ignored. The scheme match is
|
|
72
|
+
// case-insensitive per RFC 9110 §11.1 (auth-scheme is case-insensitive).
|
|
73
|
+
function _readBearer(authHeader, scheme) {
|
|
60
74
|
if (!authHeader || typeof authHeader !== "string") return null;
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
var schemeTok = (typeof scheme === "string" && scheme.length > 0) ? scheme : "Bearer";
|
|
76
|
+
// allow:dynamic-regex — schemeTok is RegExp-escaped via codepointClass.escapeRegExp,
|
|
77
|
+
// so the operator-supplied scheme matches literally and cannot inject a pattern
|
|
78
|
+
var m = authHeader.match(new RegExp("^" + codepointClass.escapeRegExp(schemeTok) + "\\s+(.+)$", "i"));
|
|
63
79
|
return m ? m[1].trim() : null;
|
|
64
80
|
}
|
|
65
81
|
|
|
@@ -86,6 +102,8 @@ function _readBearer(authHeader) {
|
|
|
86
102
|
* userLoader: async function(session): user|null, // required
|
|
87
103
|
* cookieName: string, // default "blamejs_session"
|
|
88
104
|
* tokenFrom: "both"|"cookie"|"header", // default "both"
|
|
105
|
+
* bearerScheme: string, // default "Bearer" (RFC 6750); set "Token"/"DPoP"/etc. for a gateway scheme
|
|
106
|
+
* tokenExtractor: function, // (req) → token|null; fully owns header extraction when supplied
|
|
89
107
|
* sealed: boolean,
|
|
90
108
|
* vault: object, // required when sealed
|
|
91
109
|
* requireFingerprintMatch: boolean,
|
|
@@ -108,15 +126,28 @@ function create(opts) {
|
|
|
108
126
|
validateOpts(opts, [
|
|
109
127
|
"cookieName", "tokenFrom", "sealed", "vault", "userLoader", "audit",
|
|
110
128
|
"requireFingerprintMatch", "maxAnomalyScore", "scorer",
|
|
129
|
+
"bearerScheme", "tokenExtractor",
|
|
111
130
|
], "middleware.attachUser");
|
|
112
131
|
if (typeof opts.userLoader !== "function") {
|
|
113
132
|
throw new Error("middleware.attachUser: opts.userLoader is required " +
|
|
114
133
|
"(async function (verifiedSession) → user | null)");
|
|
115
134
|
}
|
|
135
|
+
validateOpts.optionalNonEmptyString(opts.bearerScheme,
|
|
136
|
+
"middleware.attachUser: opts.bearerScheme (the Authorization scheme token, " +
|
|
137
|
+
"e.g. \"Bearer\", \"Token\", \"DPoP\")");
|
|
138
|
+
validateOpts.optionalFunction(opts.tokenExtractor,
|
|
139
|
+
"middleware.attachUser: opts.tokenExtractor (req) → token | null");
|
|
116
140
|
var cookieName = opts.cookieName || "blamejs_session";
|
|
117
141
|
var tokenFrom = opts.tokenFrom || "both";
|
|
118
142
|
var auditOn = opts.audit !== false;
|
|
119
143
|
var sealed = !!opts.sealed;
|
|
144
|
+
// Authorization-header scheme token (default "Bearer", RFC 6750 §2.1).
|
|
145
|
+
// tokenExtractor, when supplied, fully owns header-token extraction so
|
|
146
|
+
// gateway-specific schemes (a forwarded JWT in a non-standard header,
|
|
147
|
+
// DPoP-bound tokens, etc.) work without the framework assuming the
|
|
148
|
+
// RFC 6750 shape.
|
|
149
|
+
var bearerScheme = opts.bearerScheme || "Bearer";
|
|
150
|
+
var tokenExtractor = typeof opts.tokenExtractor === "function" ? opts.tokenExtractor : null;
|
|
120
151
|
// Fingerprint-drift / IP-UA pin / anomaly-score opts thread through
|
|
121
152
|
// session.verify so the documented session.create({ req,
|
|
122
153
|
// fingerprintFields }) defenses actually engage on every verify
|
|
@@ -153,7 +184,11 @@ function create(opts) {
|
|
|
153
184
|
// the header re-read here avoids the duplicate verify and the
|
|
154
185
|
// confusing "session.verify failed" audit row that would land
|
|
155
186
|
// when the bearer token is a JWT or API key, not a session ID.
|
|
156
|
-
|
|
187
|
+
if (tokenExtractor) {
|
|
188
|
+
token = tokenExtractor(req) || null;
|
|
189
|
+
} else {
|
|
190
|
+
token = _readBearer(req.headers && req.headers.authorization, bearerScheme);
|
|
191
|
+
}
|
|
157
192
|
}
|
|
158
193
|
if (!token) return next();
|
|
159
194
|
|
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
* document: { maxBytes: b.constants.BYTES.mib(25) },
|
|
70
70
|
* },
|
|
71
71
|
*
|
|
72
|
+
* // RFC 5987 filename* charsets to decode. utf-8 is always
|
|
73
|
+
* // supported (RFC 8187 §3.2); add "iso-8859-1" (RFC 5987 §3.2)
|
|
74
|
+
* // to accept legacy senders that encode filenames in Latin-1.
|
|
75
|
+
* filenameCharsets: ["utf-8"],
|
|
76
|
+
*
|
|
72
77
|
* // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
|
|
73
78
|
* // on the audit chain with the field, filename, mime, and reason.
|
|
74
79
|
* audit: b.audit,
|
|
@@ -121,6 +126,7 @@ var safeBuffer = require("../safe-buffer");
|
|
|
121
126
|
var safeJson = require("../safe-json");
|
|
122
127
|
var structuredFields = require("../structured-fields");
|
|
123
128
|
var validateOpts = require("../validate-opts");
|
|
129
|
+
var codepointClass = require("../codepoint-class");
|
|
124
130
|
var C = require("../constants");
|
|
125
131
|
var { defineClass } = require("../framework-error");
|
|
126
132
|
|
|
@@ -196,6 +202,7 @@ var DEFAULTS = Object.freeze({
|
|
|
196
202
|
fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
|
|
197
203
|
fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
|
|
198
204
|
audit: null, // when wired, file-rejection emits an audit event
|
|
205
|
+
filenameCharsets: ["utf-8"], // RFC 5987 filename* charsets to decode; add "iso-8859-1" to opt that legacy charset in
|
|
199
206
|
contentTypes: ["multipart/form-data"],
|
|
200
207
|
},
|
|
201
208
|
});
|
|
@@ -344,9 +351,16 @@ function _detectSmuggling(req) {
|
|
|
344
351
|
function _writeError(res, status, message, code) {
|
|
345
352
|
if (res.headersSent) return;
|
|
346
353
|
var body = JSON.stringify({ error: message, code: code });
|
|
354
|
+
// Connection: close on a body-parse rejection (malformed JSON, poisoned
|
|
355
|
+
// key, oversize payload) so an upstream proxy can't reuse a socket whose
|
|
356
|
+
// request stream we abandoned mid-body. Pairing the 4xx with a forced
|
|
357
|
+
// socket teardown closes the desync window a partially-consumed body
|
|
358
|
+
// would otherwise leave open (RFC 9112 §9.6 — a server MAY close after
|
|
359
|
+
// an error response; doing so here prevents request-smuggling reuse).
|
|
347
360
|
res.writeHead(status, {
|
|
348
361
|
"Content-Type": "application/json; charset=utf-8",
|
|
349
362
|
"Content-Length": Buffer.byteLength(body),
|
|
363
|
+
"Connection": "close",
|
|
350
364
|
});
|
|
351
365
|
res.end(body);
|
|
352
366
|
}
|
|
@@ -600,28 +614,58 @@ function _parseMultipartHeaders(rawHeaders) {
|
|
|
600
614
|
return out;
|
|
601
615
|
}
|
|
602
616
|
|
|
617
|
+
// Percent-decode an RFC 5987 ext-value's value segment under iso-8859-1.
|
|
618
|
+
// RFC 5987 §3.2 / RFC 2231 §7 define iso-8859-1 (Latin-1) as the only
|
|
619
|
+
// non-utf-8 charset a recipient is expected to understand: each decoded
|
|
620
|
+
// byte is itself the Latin-1 code point, so a byte b maps directly to
|
|
621
|
+
// U+00bb. We percent-unescape to raw bytes, then map each byte to its
|
|
622
|
+
// code point. Returns null on a malformed `%`-escape.
|
|
623
|
+
function _percentDecodeLatin1(encoded) {
|
|
624
|
+
var out = "";
|
|
625
|
+
for (var i = 0; i < encoded.length; i += 1) {
|
|
626
|
+
var ch = encoded.charAt(i);
|
|
627
|
+
if (ch === "%") {
|
|
628
|
+
var hex = encoded.substr(i + 1, 2);
|
|
629
|
+
if (hex.length !== 2 || !codepointClass.HEX_PAIR_RE.test(hex)) return null;
|
|
630
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
631
|
+
i += 2;
|
|
632
|
+
} else {
|
|
633
|
+
out += ch;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
603
639
|
// RFC 5987 / 8187 — `filename*=UTF-8''percent%20encoded.txt` extended
|
|
604
|
-
// parameter form for non-ASCII filenames.
|
|
605
|
-
// (
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
640
|
+
// parameter form for non-ASCII filenames. utf-8 is always accepted
|
|
641
|
+
// (RFC 8187 §3.2 mandates support); iso-8859-1 (RFC 5987 §3.2 / RFC 2231
|
|
642
|
+
// §7) decodes only when the operator opts it in via
|
|
643
|
+
// `multipart.filenameCharsets`. Any other charset is refused to keep the
|
|
644
|
+
// decode path bounded to the two RFC-defined encodings. The language tag
|
|
645
|
+
// (between the two `'`s) is permitted but ignored. `allowed` is a
|
|
646
|
+
// lower-cased charset allowlist; it always includes "utf-8".
|
|
647
|
+
function _decodeRfc5987(raw, allowed) {
|
|
609
648
|
if (typeof raw !== "string") return null;
|
|
610
649
|
var firstTick = raw.indexOf("'");
|
|
611
650
|
if (firstTick === -1) return null;
|
|
612
651
|
var secondTick = raw.indexOf("'", firstTick + 1);
|
|
613
652
|
if (secondTick === -1) return null;
|
|
614
653
|
var charset = raw.slice(0, firstTick).toLowerCase();
|
|
615
|
-
if (charset !== "utf-8") return null; // RFC 5987 mandated charset; refuse anything else
|
|
616
654
|
var encoded = raw.slice(secondTick + 1);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
655
|
+
if (charset === "utf-8") {
|
|
656
|
+
try {
|
|
657
|
+
return decodeURIComponent(encoded);
|
|
658
|
+
} catch (_e) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
621
661
|
}
|
|
662
|
+
if (charset === "iso-8859-1" && allowed && allowed.indexOf("iso-8859-1") !== -1) {
|
|
663
|
+
return _percentDecodeLatin1(encoded);
|
|
664
|
+
}
|
|
665
|
+
return null; // charset not enabled — refuse
|
|
622
666
|
}
|
|
623
667
|
|
|
624
|
-
function _parseHeaderParams(headerValue) {
|
|
668
|
+
function _parseHeaderParams(headerValue, filenameCharsets) {
|
|
625
669
|
// Content-Disposition: form-data; name="field"; filename="x.txt"
|
|
626
670
|
// Returns { _value: "form-data", name: "field", filename: "x.txt" }
|
|
627
671
|
// RFC 5987 / 8187 — when a `filename*=UTF-8''...` extended parameter
|
|
@@ -646,7 +690,7 @@ function _parseHeaderParams(headerValue) {
|
|
|
646
690
|
var _unq = structuredFields.unquoteSfString(v);
|
|
647
691
|
if (_unq !== null) v = _unq;
|
|
648
692
|
if (k.charAt(k.length - 1) === "*") {
|
|
649
|
-
var decoded = _decodeRfc5987(v);
|
|
693
|
+
var decoded = _decodeRfc5987(v, filenameCharsets);
|
|
650
694
|
if (decoded !== null) {
|
|
651
695
|
var bareKey = k.slice(0, -1);
|
|
652
696
|
if (bareKey === "filename") extName = decoded;
|
|
@@ -682,6 +726,17 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
682
726
|
true, HTTP_STATUS.BAD_REQUEST
|
|
683
727
|
);
|
|
684
728
|
}
|
|
729
|
+
// RFC 5987 filename* charsets the operator opts into decoding. utf-8 is
|
|
730
|
+
// always present (RFC 8187 §3.2 mandates it); operators add "iso-8859-1"
|
|
731
|
+
// for legacy senders. Lower-cased once here so the per-part decode does
|
|
732
|
+
// a plain membership check.
|
|
733
|
+
var filenameCharsets = ["utf-8"];
|
|
734
|
+
if (Array.isArray(opts.filenameCharsets)) {
|
|
735
|
+
filenameCharsets = opts.filenameCharsets.map(function (c) {
|
|
736
|
+
return String(c).toLowerCase();
|
|
737
|
+
});
|
|
738
|
+
if (filenameCharsets.indexOf("utf-8") === -1) filenameCharsets.push("utf-8");
|
|
739
|
+
}
|
|
685
740
|
// storage: "memory" buffers file parts in RAM (capped by fileSize ×
|
|
686
741
|
// fileCount, the same DoS bound as disk mode) and exposes each file as
|
|
687
742
|
// req.files[].buffer with no filesystem touch — the read-only /
|
|
@@ -870,7 +925,7 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
870
925
|
}
|
|
871
926
|
pending = pending.slice(headEnd + 4);
|
|
872
927
|
// Decode Content-Disposition.
|
|
873
|
-
var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
|
|
928
|
+
var cd = _parseHeaderParams(currentHeaders["content-disposition"], filenameCharsets);
|
|
874
929
|
if (cd._value !== "form-data" || typeof cd.name !== "string" || cd.name.length === 0) {
|
|
875
930
|
done(new BodyParserError("body-parser/multipart-bad-disposition",
|
|
876
931
|
"multipart part missing form-data Content-Disposition", true, HTTP_STATUS.BAD_REQUEST));
|
|
@@ -1211,7 +1266,8 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
1211
1266
|
* raw: false | { limit, contentTypes },
|
|
1212
1267
|
* multipart: false | {
|
|
1213
1268
|
* storage, tmpDir, fileSize, totalSize, fileCount, fieldCount,
|
|
1214
|
-
* fieldSize, mimeAllowlist, fileFilter, fields, audit,
|
|
1269
|
+
* fieldSize, mimeAllowlist, fileFilter, fields, audit,
|
|
1270
|
+
* filenameCharsets, contentTypes,
|
|
1215
1271
|
* },
|
|
1216
1272
|
* keepRawBody: boolean, // expose req.bodyRaw for webhook signing
|
|
1217
1273
|
* }
|
|
@@ -99,9 +99,21 @@ function _normalizeOne(reportLike) {
|
|
|
99
99
|
* `csp.violation`, and forwarded to the operator's `onReport`
|
|
100
100
|
* callback for metrics or alerting.
|
|
101
101
|
*
|
|
102
|
+
* The rejection paths (405 / 413 / 400) are otherwise empty-bodied —
|
|
103
|
+
* the spec'd Reporting API (W3C Reporting API §3.1) ignores the
|
|
104
|
+
* response body, so there's nothing for the browser to read. `onReject`
|
|
105
|
+
* surfaces these refusals to the operator for the same metrics /
|
|
106
|
+
* alerting use as `onReport`: a flood of 413s signals a misconfigured
|
|
107
|
+
* `Reporting-Endpoints` URL or a report-bomb. It receives
|
|
108
|
+
* `(req, res, { status, reason })` where `reason` is one of
|
|
109
|
+
* `method-not-allowed` / `payload-too-large` / `invalid-json`. Invoked
|
|
110
|
+
* after the rejection response is written; a throwing hook is swallowed
|
|
111
|
+
* so a broken metrics sink can't crash the endpoint.
|
|
112
|
+
*
|
|
102
113
|
* @opts
|
|
103
114
|
* {
|
|
104
115
|
* onReport: function(report): void,
|
|
116
|
+
* onReject: function(req, res, { status, reason }): void,
|
|
105
117
|
* maxBytes: number, // default 64 KiB
|
|
106
118
|
* audit: boolean, // default true
|
|
107
119
|
* }
|
|
@@ -118,23 +130,38 @@ function _normalizeOne(reportLike) {
|
|
|
118
130
|
*/
|
|
119
131
|
function create(opts) {
|
|
120
132
|
opts = opts || {};
|
|
121
|
-
validateOpts(opts, ["audit", "onReport", "maxBytes"], "middleware.cspReport");
|
|
133
|
+
validateOpts(opts, ["audit", "onReport", "onReject", "maxBytes"], "middleware.cspReport");
|
|
134
|
+
if (opts.onReject !== undefined && opts.onReject !== null &&
|
|
135
|
+
typeof opts.onReject !== "function") {
|
|
136
|
+
throw new TypeError("middleware.cspReport: opts.onReject must be a function");
|
|
137
|
+
}
|
|
122
138
|
var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
|
|
123
139
|
? opts.maxBytes : DEFAULT_MAX_BYTES;
|
|
124
140
|
var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
|
|
141
|
+
var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
|
|
142
|
+
|
|
143
|
+
// Drop-silent observability sink — the rejection response is already
|
|
144
|
+
// on the wire; a throwing metrics hook must not crash the endpoint.
|
|
145
|
+
function _emitReject(req, res, status, reason) {
|
|
146
|
+
if (!onReject) return;
|
|
147
|
+
try { onReject(req, res, { status: status, reason: reason }); }
|
|
148
|
+
catch (_e) { /* hook best-effort */ }
|
|
149
|
+
}
|
|
125
150
|
|
|
126
151
|
return async function cspReport(req, res, _next) {
|
|
127
152
|
if (req.method !== "POST") {
|
|
128
153
|
res.writeHead(405, { "Allow": "POST" }); // HTTP 405 status
|
|
129
154
|
res.end();
|
|
155
|
+
_emitReject(req, res, 405, "method-not-allowed");
|
|
130
156
|
return;
|
|
131
157
|
}
|
|
132
158
|
var body;
|
|
133
159
|
try {
|
|
134
|
-
body = await safeBuffer.
|
|
160
|
+
body = await safeBuffer.collectStream(req, { maxBytes: maxBytes });
|
|
135
161
|
} catch (_e) {
|
|
136
162
|
res.writeHead(413); // HTTP 413 status
|
|
137
163
|
res.end();
|
|
164
|
+
_emitReject(req, res, 413, "payload-too-large");
|
|
138
165
|
return;
|
|
139
166
|
}
|
|
140
167
|
var parsed;
|
|
@@ -142,6 +169,7 @@ function create(opts) {
|
|
|
142
169
|
catch (_e) {
|
|
143
170
|
res.writeHead(400); // HTTP 400 status
|
|
144
171
|
res.end();
|
|
172
|
+
_emitReject(req, res, 400, "invalid-json");
|
|
145
173
|
return;
|
|
146
174
|
}
|
|
147
175
|
var reports = Array.isArray(parsed) ? parsed : [parsed];
|
|
@@ -79,25 +79,40 @@ function denyResponse(req, res, ctx) {
|
|
|
79
79
|
if (_isFn(ctx.onDeny)) {
|
|
80
80
|
try {
|
|
81
81
|
var returned = ctx.onDeny(req, res, info);
|
|
82
|
-
if (res.writableEnded) return returned;
|
|
83
|
-
// Hook ran but did not
|
|
84
|
-
// the response can never hang on a no-op hook.
|
|
82
|
+
if (res.writableEnded || res.headersSent) return returned;
|
|
83
|
+
// Hook ran but did not commit the response — fall through to the
|
|
84
|
+
// default so the response can never hang on a no-op hook. A
|
|
85
|
+
// wrapping consumer that already sent headers (without flipping
|
|
86
|
+
// writableEnded) counts as committed: re-entering writeHead below
|
|
87
|
+
// would throw "headers already sent".
|
|
85
88
|
} catch (e) {
|
|
86
89
|
if (_isFn(ctx.onThrow)) {
|
|
87
90
|
try { ctx.onThrow(e); } catch (_e) { /* drop-silent */ }
|
|
88
91
|
}
|
|
89
|
-
if (res.writableEnded) return undefined;
|
|
90
|
-
// Hook threw before
|
|
92
|
+
if (res.writableEnded || res.headersSent) return undefined;
|
|
93
|
+
// Hook threw before committing the response — fall through to
|
|
94
|
+
// the default.
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
if (res.writableEnded || !_isFn(res.writeHead)) return undefined;
|
|
98
|
+
if (res.writableEnded || res.headersSent || !_isFn(res.writeHead)) return undefined;
|
|
95
99
|
|
|
96
100
|
var extra = (ctx.headers && typeof ctx.headers === "object") ? ctx.headers : null;
|
|
97
101
|
|
|
98
102
|
if (ctx.problem) {
|
|
99
103
|
var fields = { status: ctx.status };
|
|
100
|
-
if (ctx.problemType)
|
|
104
|
+
if (ctx.problemType) {
|
|
105
|
+
fields.type = ctx.problemType;
|
|
106
|
+
} else if (typeof ctx.problemCode === "string" && ctx.problemCode.length > 0) {
|
|
107
|
+
// No explicit type URI: derive one from problemCode using the
|
|
108
|
+
// same `<base>/<code>` convention as problemDetails.fromError, so
|
|
109
|
+
// a 429 carrying problemCode reads `<base>/rate-limit-exceeded`
|
|
110
|
+
// rather than defaulting to "about:blank". RFC 9457 §3.1.1 lets
|
|
111
|
+
// the type be any URI reference; sanitize the suffix into RFC
|
|
112
|
+
// 3986 unreserved + "/" path chars, matching fromError exactly.
|
|
113
|
+
fields.type = problemDetails.getBase() + "/" +
|
|
114
|
+
ctx.problemCode.replace(/[^A-Za-z0-9\-._/]/g, "-");
|
|
115
|
+
}
|
|
101
116
|
if (ctx.problemTitle) fields.title = ctx.problemTitle;
|
|
102
117
|
if (ctx.problemDetail) fields.detail = ctx.problemDetail;
|
|
103
118
|
if (ctx.problemExt && typeof ctx.problemExt === "object") {
|
|
@@ -22,15 +22,50 @@
|
|
|
22
22
|
* If `accessControl: "public"` (the default), the middleware emits
|
|
23
23
|
* `Access-Control-Allow-Origin: *` so external doc tooling can fetch.
|
|
24
24
|
* For internal-only docs operators set `accessControl: "same-origin"`
|
|
25
|
-
* which omits the CORS header.
|
|
25
|
+
* which omits the CORS header. To allow exactly one external origin set
|
|
26
|
+
* `accessControl: { allowOrigin: "https://docs.example.com" }`; the
|
|
27
|
+
* origin is validated and echoed verbatim with `Vary: Origin`.
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
var nodeCrypto = require("node:crypto");
|
|
29
31
|
var validateOpts = require("../validate-opts");
|
|
30
32
|
var lazyRequire = require("../lazy-require");
|
|
33
|
+
var safeUrl = require("../safe-url");
|
|
31
34
|
var { defineClass } = require("../framework-error");
|
|
32
35
|
var OpenApiError = defineClass("OpenApiError", { alwaysPermanent: true });
|
|
33
36
|
|
|
37
|
+
// Validate an operator-supplied accessControl.allowOrigin and return the
|
|
38
|
+
// canonical `scheme://host[:port]` string for the Access-Control-Allow-
|
|
39
|
+
// Origin response header. CORS (Fetch Standard §3.2.1) requires a single
|
|
40
|
+
// concrete origin with no path / query / fragment; the empty-string and
|
|
41
|
+
// "*" wildcard forms are spelled separately ("same-origin" / "public").
|
|
42
|
+
// Parsing through safeUrl rejects header-injection bytes (CR/LF) and
|
|
43
|
+
// userinfo, and confirms the value is a real http(s) origin. Throws so
|
|
44
|
+
// the operator catches a typo'd allowOrigin at boot.
|
|
45
|
+
function _canonicalAllowOrigin(value, label) {
|
|
46
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
47
|
+
throw new OpenApiError("openapi/bad-access-control",
|
|
48
|
+
label + ": accessControl.allowOrigin must be a non-empty origin string " +
|
|
49
|
+
"(e.g. \"https://docs.example.com\")");
|
|
50
|
+
}
|
|
51
|
+
var parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new OpenApiError("openapi/bad-access-control",
|
|
56
|
+
label + ": accessControl.allowOrigin '" + value + "' is not a valid " +
|
|
57
|
+
"http(s) origin: " + ((e && e.message) || String(e)));
|
|
58
|
+
}
|
|
59
|
+
var path = parsed.pathname || "";
|
|
60
|
+
if ((path !== "" && path !== "/") || parsed.search || parsed.hash) {
|
|
61
|
+
throw new OpenApiError("openapi/bad-access-control",
|
|
62
|
+
label + ": accessControl.allowOrigin must be a bare origin " +
|
|
63
|
+
"(scheme://host[:port]) with no path / query / fragment; got '" + value + "'");
|
|
64
|
+
}
|
|
65
|
+
var port = parsed.port;
|
|
66
|
+
return parsed.protocol + "//" + parsed.hostname.toLowerCase() + (port ? ":" + port : "");
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
var openapiYaml = lazyRequire(function () { return require("../openapi-yaml"); });
|
|
35
70
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
36
71
|
|
|
@@ -45,7 +80,9 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
45
80
|
* else falls through. SHA3-512 ETag enables conditional 304. With
|
|
46
81
|
* `accessControl: "public"` (default) emits
|
|
47
82
|
* `Access-Control-Allow-Origin: *` so external doc tooling can
|
|
48
|
-
* fetch; `same-origin` omits the CORS header for internal-only docs
|
|
83
|
+
* fetch; `same-origin` omits the CORS header for internal-only docs;
|
|
84
|
+
* `{ allowOrigin: "https://docs.example.com" }` echoes one validated
|
|
85
|
+
* origin with `Vary: Origin`.
|
|
49
86
|
*
|
|
50
87
|
* @opts
|
|
51
88
|
* {
|
|
@@ -84,6 +121,17 @@ function create(opts) {
|
|
|
84
121
|
var cacheControl = (typeof opts.cacheControl === "string" && opts.cacheControl.length > 0)
|
|
85
122
|
? opts.cacheControl : "public, max-age=300";
|
|
86
123
|
var accessControl = opts.accessControl || "public";
|
|
124
|
+
// Resolve the Access-Control-Allow-Origin value once at config time.
|
|
125
|
+
// "public" → "*"; an { allowOrigin } object → the canonical origin
|
|
126
|
+
// (validated, throws on a bad value); "same-origin" / anything else →
|
|
127
|
+
// null (no CORS header emitted).
|
|
128
|
+
var allowOriginHeader = null;
|
|
129
|
+
if (accessControl === "public") {
|
|
130
|
+
allowOriginHeader = "*";
|
|
131
|
+
} else if (accessControl && typeof accessControl === "object" &&
|
|
132
|
+
typeof accessControl.allowOrigin === "string") {
|
|
133
|
+
allowOriginHeader = _canonicalAllowOrigin(accessControl.allowOrigin, "openapiServe");
|
|
134
|
+
}
|
|
87
135
|
var auditOn = opts.audit !== false;
|
|
88
136
|
|
|
89
137
|
if (typeof pathJson !== "string" || pathJson.charAt(0) !== "/") {
|
|
@@ -123,8 +171,12 @@ function create(opts) {
|
|
|
123
171
|
"Cache-Control": cacheControl,
|
|
124
172
|
"ETag": etag,
|
|
125
173
|
};
|
|
126
|
-
if (
|
|
127
|
-
headers["Access-Control-Allow-Origin"] =
|
|
174
|
+
if (allowOriginHeader !== null) {
|
|
175
|
+
headers["Access-Control-Allow-Origin"] = allowOriginHeader;
|
|
176
|
+
// A specific (non-"*") origin makes the response vary by Origin;
|
|
177
|
+
// advertise it so shared caches don't serve one operator's allowed
|
|
178
|
+
// origin to another's request (Fetch Standard §3.2.1).
|
|
179
|
+
if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
|
|
128
180
|
}
|
|
129
181
|
res.writeHead(200, headers); // HTTP 200
|
|
130
182
|
res.end(body);
|
|
@@ -279,10 +279,13 @@ function _readJsonBody(req) {
|
|
|
279
279
|
return Promise.resolve(safeJson.parse(req.body.toString("utf8"), { maxBytes: MAX }));
|
|
280
280
|
}
|
|
281
281
|
if (req.body && typeof req.body === "object") return Promise.resolve(req.body);
|
|
282
|
-
return safeBuffer.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
return safeBuffer.collectStream(req, {
|
|
283
|
+
maxBytes: MAX,
|
|
284
|
+
errorClass: ScimServerError,
|
|
285
|
+
sizeCode: "middleware/scim-server/body-too-large",
|
|
286
|
+
}).then(function (buf) {
|
|
287
|
+
return safeJson.parse(buf.toString("utf8"), { maxBytes: MAX });
|
|
288
|
+
});
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
function _assertSchema(body, expectedSchema) {
|