@blamejs/core 0.14.5 → 0.14.6
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 +1 -0
- package/lib/cra-report.js +3 -3
- package/lib/middleware/age-gate.js +20 -7
- package/lib/middleware/bearer-auth.js +36 -35
- package/lib/middleware/bot-guard.js +17 -5
- package/lib/middleware/cors.js +28 -12
- package/lib/middleware/csrf-protect.js +22 -14
- package/lib/middleware/daily-byte-quota.js +27 -13
- package/lib/middleware/deny-response.js +140 -0
- package/lib/middleware/dpop.js +32 -19
- package/lib/middleware/fetch-metadata.js +21 -12
- package/lib/middleware/host-allowlist.js +19 -8
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/network-allowlist.js +24 -10
- package/lib/middleware/rate-limit.js +22 -5
- package/lib/middleware/require-aal.js +25 -10
- package/lib/middleware/require-auth.js +32 -16
- package/lib/middleware/require-bound-key.js +49 -18
- package/lib/middleware/require-content-type.js +19 -8
- package/lib/middleware/require-methods.js +17 -7
- package/lib/middleware/require-mtls.js +27 -14
- package/lib/network.js +4 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared deny-path response writer for the request-lifecycle
|
|
4
|
+
* middlewares that refuse a request (401 / 403 / 405 / 415 / 429 /
|
|
5
|
+
* 451 / misdirected-request). Every deny-path middleware routes its
|
|
6
|
+
* refusal through `denyResponse` so a consumer gets one uniform way
|
|
7
|
+
* to shape it, instead of each middleware hardcoding its own body +
|
|
8
|
+
* Content-Type.
|
|
9
|
+
*
|
|
10
|
+
* Three resolution modes, checked in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. `onDeny(req, res, info)` operator hook — when supplied, the
|
|
13
|
+
* consumer owns the response. `info` carries the machine
|
|
14
|
+
* `status` / `reason` plus the middleware-specific fields. The
|
|
15
|
+
* hook is wrapped so a throw is audited (via `ctx.onThrow`) and
|
|
16
|
+
* then falls through to the default write rather than crashing
|
|
17
|
+
* the request that triggered the refusal. A hook that returns
|
|
18
|
+
* without writing also falls through — the response can never
|
|
19
|
+
* hang on a no-op hook.
|
|
20
|
+
*
|
|
21
|
+
* 2. `problem: true` — emit RFC 9457 `application/problem+json` by
|
|
22
|
+
* composing `b.problemDetails`. The middleware supplies the
|
|
23
|
+
* `type` / `title` / `detail` and any extension members; the
|
|
24
|
+
* deny-path response headers (`Allow` / `WWW-Authenticate` /
|
|
25
|
+
* `Retry-After` / `Accept`) are merged onto the problem
|
|
26
|
+
* response so content negotiation does not drop them.
|
|
27
|
+
*
|
|
28
|
+
* 3. Default — the middleware's existing body + Content-Type. No
|
|
29
|
+
* behavior change when neither knob is set.
|
|
30
|
+
*
|
|
31
|
+
* This is an internal helper (no public `b.*` surface); the consumer
|
|
32
|
+
* contract is the `onDeny` / `problemDetails` opts documented on each
|
|
33
|
+
* middleware that composes it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var problemDetails = require("../problem-details");
|
|
37
|
+
|
|
38
|
+
function _isFn(x) { return typeof x === "function"; }
|
|
39
|
+
|
|
40
|
+
function _mergeInto(target, extra) {
|
|
41
|
+
if (!extra || typeof extra !== "object") return target;
|
|
42
|
+
var keys = Object.keys(extra);
|
|
43
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
44
|
+
target[keys[i]] = extra[keys[i]];
|
|
45
|
+
}
|
|
46
|
+
return target;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a deny-path refusal through the uniform hook / problem+json
|
|
51
|
+
* / default chain. Returns whatever the `onDeny` hook returns when it
|
|
52
|
+
* owns the response, otherwise `undefined`.
|
|
53
|
+
*
|
|
54
|
+
* ctx fields:
|
|
55
|
+
* onDeny: function (req, res, info) | null — operator hook
|
|
56
|
+
* problem: boolean — emit application/problem+json
|
|
57
|
+
* status: number — HTTP status (100..599)
|
|
58
|
+
* info: object — passed verbatim to onDeny; also seeds
|
|
59
|
+
* the problem document (status / reason)
|
|
60
|
+
* problemType: string? — RFC 9457 `type` (URI reference); when
|
|
61
|
+
* absent, built from `problemCode`
|
|
62
|
+
* problemCode: string? — type-URI suffix; resolves to
|
|
63
|
+
* `<problemDetails base>/<code>` (the same
|
|
64
|
+
* `<base>/<code>` convention as fromError)
|
|
65
|
+
* problemTitle: string? — RFC 9457 `title`
|
|
66
|
+
* problemDetail: string? — RFC 9457 `detail`
|
|
67
|
+
* problemExt: object? — extra problem members (reserved names
|
|
68
|
+
* dropped); siblings per RFC 9457 §3.2
|
|
69
|
+
* headers: object? — extra response headers (Allow /
|
|
70
|
+
* WWW-Authenticate / Retry-After / Accept
|
|
71
|
+
* / Cache-Control)
|
|
72
|
+
* contentType: string — default-mode Content-Type
|
|
73
|
+
* body: string|Buffer — default-mode body
|
|
74
|
+
* onThrow: function (err) ? — audit sink when onDeny throws
|
|
75
|
+
*/
|
|
76
|
+
function denyResponse(req, res, ctx) {
|
|
77
|
+
var info = (ctx.info && typeof ctx.info === "object") ? ctx.info : {};
|
|
78
|
+
|
|
79
|
+
if (_isFn(ctx.onDeny)) {
|
|
80
|
+
try {
|
|
81
|
+
var returned = ctx.onDeny(req, res, info);
|
|
82
|
+
if (res.writableEnded) return returned;
|
|
83
|
+
// Hook ran but did not write — fall through to the default so
|
|
84
|
+
// the response can never hang on a no-op hook.
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (_isFn(ctx.onThrow)) {
|
|
87
|
+
try { ctx.onThrow(e); } catch (_e) { /* drop-silent */ }
|
|
88
|
+
}
|
|
89
|
+
if (res.writableEnded) return undefined;
|
|
90
|
+
// Hook threw before writing — fall through to the default.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (res.writableEnded || !_isFn(res.writeHead)) return undefined;
|
|
95
|
+
|
|
96
|
+
var extra = (ctx.headers && typeof ctx.headers === "object") ? ctx.headers : null;
|
|
97
|
+
|
|
98
|
+
if (ctx.problem) {
|
|
99
|
+
var fields = { status: ctx.status };
|
|
100
|
+
if (ctx.problemType) fields.type = ctx.problemType;
|
|
101
|
+
if (ctx.problemTitle) fields.title = ctx.problemTitle;
|
|
102
|
+
if (ctx.problemDetail) fields.detail = ctx.problemDetail;
|
|
103
|
+
if (ctx.problemExt && typeof ctx.problemExt === "object") {
|
|
104
|
+
var ek = Object.keys(ctx.problemExt);
|
|
105
|
+
for (var i = 0; i < ek.length; i += 1) {
|
|
106
|
+
if (problemDetails.RESERVED_FIELDS.indexOf(ek[i]) === -1) {
|
|
107
|
+
fields[ek[i]] = ctx.problemExt[ek[i]];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
var problem;
|
|
112
|
+
try {
|
|
113
|
+
problem = problemDetails.create(fields);
|
|
114
|
+
} catch (_e) {
|
|
115
|
+
// A bad extension shape (prototype-pollution key, out-of-range
|
|
116
|
+
// status) must not turn a refusal into a 500 — degrade to the
|
|
117
|
+
// bare status document.
|
|
118
|
+
problem = problemDetails.create({ status: ctx.status });
|
|
119
|
+
}
|
|
120
|
+
// Set the deny-path headers before respond() so content
|
|
121
|
+
// negotiation does not lose Allow / WWW-Authenticate / Retry-After.
|
|
122
|
+
if (extra) {
|
|
123
|
+
var hk = Object.keys(extra);
|
|
124
|
+
for (var h = 0; h < hk.length; h += 1) {
|
|
125
|
+
res.setHeader(hk[h], extra[hk[h]]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
problemDetails.respond(res, problem);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
|
|
133
|
+
res.writeHead(ctx.status, head);
|
|
134
|
+
res.end((ctx.body === undefined || ctx.body === null) ? "" : ctx.body);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
denyResponse: denyResponse,
|
|
140
|
+
};
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -40,6 +40,7 @@ var bCrypto = require("../crypto");
|
|
|
40
40
|
var lazyRequire = require("../lazy-require");
|
|
41
41
|
var requestHelpers = require("../request-helpers");
|
|
42
42
|
var validateOpts = require("../validate-opts");
|
|
43
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
43
44
|
var { AuthError } = require("../framework-error");
|
|
44
45
|
|
|
45
46
|
var dpop = lazyRequire(function () { return require("../auth/dpop"); });
|
|
@@ -49,20 +50,28 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
49
50
|
// of entropy after base64url, far above the spec's "unpredictable" bar).
|
|
50
51
|
var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
|
|
51
52
|
|
|
52
|
-
function _writeUnauthorized(res, errorCode, description, freshNonce) {
|
|
53
|
-
if (res.headersSent) return;
|
|
53
|
+
function _writeUnauthorized(req, res, errorCode, description, freshNonce, onDeny, problemMode) {
|
|
54
54
|
var body = JSON.stringify({ error: errorCode, error_description: description });
|
|
55
55
|
// RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
|
|
56
56
|
var challenge = 'DPoP error="' + errorCode + '", error_description="' +
|
|
57
57
|
description.replace(/"/g, "'") + '"';
|
|
58
|
-
var headers = {
|
|
59
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
60
|
-
"Content-Length": Buffer.byteLength(body),
|
|
58
|
+
var headers = {
|
|
61
59
|
"WWW-Authenticate": challenge,
|
|
62
60
|
};
|
|
63
61
|
if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
denyResponse(req, res, {
|
|
63
|
+
onDeny: onDeny,
|
|
64
|
+
problem: problemMode,
|
|
65
|
+
status: 401, // HTTP 401 status
|
|
66
|
+
info: { status: 401, reason: errorCode, error_description: description },
|
|
67
|
+
problemCode: "dpop-" + errorCode.replace(/_/g, "-"),
|
|
68
|
+
problemTitle: "Unauthorized",
|
|
69
|
+
problemDetail: description,
|
|
70
|
+
problemExt: { error: errorCode, error_description: description },
|
|
71
|
+
headers: headers,
|
|
72
|
+
contentType: "application/json; charset=utf-8",
|
|
73
|
+
body: body,
|
|
74
|
+
});
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
// RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
|
|
@@ -201,6 +210,8 @@ function _reconstructHtu(req, mopts) {
|
|
|
201
210
|
* nonceRotateSec: number,
|
|
202
211
|
* requireNonce: boolean,
|
|
203
212
|
* audit: boolean, // default true
|
|
213
|
+
* onDeny: function(req, res, info): void, // own the 401; info = { status, reason, error_description }
|
|
214
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
204
215
|
* }
|
|
205
216
|
*
|
|
206
217
|
* @example
|
|
@@ -220,9 +231,11 @@ function create(opts) {
|
|
|
220
231
|
// v0.9.4 — opt-in trust gate for X-Forwarded-Proto/Host when
|
|
221
232
|
// reconstructing htu. Default off (audit 2026-05-11); operators
|
|
222
233
|
// with a confirmed-trusted front proxy set this to `true`.
|
|
223
|
-
"trustForwardedHeaders",
|
|
234
|
+
"trustForwardedHeaders", "onDeny", "problemDetails",
|
|
224
235
|
], "middleware.dpop");
|
|
225
236
|
|
|
237
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
238
|
+
var problemMode = opts.problemDetails === true;
|
|
226
239
|
var auditOn = opts.audit !== false;
|
|
227
240
|
var algorithms = opts.algorithms;
|
|
228
241
|
var iatWindowSec = opts.iatWindowSec;
|
|
@@ -265,14 +278,14 @@ function create(opts) {
|
|
|
265
278
|
var middleware = async function dpopMiddleware(req, res, next) {
|
|
266
279
|
var proofHeader = req.headers && req.headers.dpop;
|
|
267
280
|
if (typeof proofHeader !== "string" || proofHeader.length === 0) {
|
|
268
|
-
return _writeUnauthorized(res,
|
|
281
|
+
return _writeUnauthorized(req, res,
|
|
269
282
|
nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
|
|
270
|
-
"DPoP header required", _freshNonce());
|
|
283
|
+
"DPoP header required", _freshNonce(), onDeny, problemMode);
|
|
271
284
|
}
|
|
272
285
|
// RFC 9449 §4.1 — only ONE DPoP header value per request.
|
|
273
286
|
if (Array.isArray(proofHeader)) {
|
|
274
|
-
return _writeUnauthorized(res, "invalid_dpop_proof",
|
|
275
|
-
"multiple DPoP headers are not allowed");
|
|
287
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
288
|
+
"multiple DPoP headers are not allowed", null, onDeny, problemMode);
|
|
276
289
|
}
|
|
277
290
|
// AUTH-15 — RFC 9449 §4.1 single-value invariant. node:http
|
|
278
291
|
// collapses repeated headers into a comma-joined string when the
|
|
@@ -283,13 +296,13 @@ function create(opts) {
|
|
|
283
296
|
// verify() call below would only see the first one, leaving the
|
|
284
297
|
// second unprocessed).
|
|
285
298
|
if (proofHeader.indexOf(",") !== -1) {
|
|
286
|
-
return _writeUnauthorized(res, "invalid_dpop_proof",
|
|
287
|
-
"multiple DPoP proofs in one header value are not allowed");
|
|
299
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
300
|
+
"multiple DPoP proofs in one header value are not allowed", null, onDeny, problemMode);
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
|
|
291
304
|
if (!htu) {
|
|
292
|
-
return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
|
|
305
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof", "could not reconstruct htu", null, onDeny, problemMode);
|
|
293
306
|
}
|
|
294
307
|
var htm = (req.method || "").toUpperCase();
|
|
295
308
|
|
|
@@ -340,9 +353,9 @@ function create(opts) {
|
|
|
340
353
|
if (e && (e.code === "auth-dpop/missing-nonce" || e.code === "auth-dpop/nonce-mismatch")) {
|
|
341
354
|
errorCode = "use_dpop_nonce";
|
|
342
355
|
}
|
|
343
|
-
return _writeUnauthorized(res, errorCode,
|
|
356
|
+
return _writeUnauthorized(req, res, errorCode,
|
|
344
357
|
(e && e.message) || "DPoP proof verification failed",
|
|
345
|
-
_freshNonce());
|
|
358
|
+
_freshNonce(), onDeny, problemMode);
|
|
346
359
|
}
|
|
347
360
|
|
|
348
361
|
// Server-managed nonce check — payload MUST carry a recognized
|
|
@@ -360,8 +373,8 @@ function create(opts) {
|
|
|
360
373
|
});
|
|
361
374
|
} catch (_ignored) { /* drop-silent */ }
|
|
362
375
|
}
|
|
363
|
-
return _writeUnauthorized(res, "use_dpop_nonce",
|
|
364
|
-
"DPoP-Nonce required (server-managed challenge)", _freshNonce());
|
|
376
|
+
return _writeUnauthorized(req, res, "use_dpop_nonce",
|
|
377
|
+
"DPoP-Nonce required (server-managed challenge)", _freshNonce(), onDeny, problemMode);
|
|
365
378
|
}
|
|
366
379
|
}
|
|
367
380
|
|
|
@@ -36,20 +36,25 @@
|
|
|
36
36
|
var requestHelpers = require("../request-helpers");
|
|
37
37
|
var validateOpts = require("../validate-opts");
|
|
38
38
|
var lazyRequire = require("../lazy-require");
|
|
39
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
39
40
|
|
|
40
41
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
41
42
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
42
43
|
|
|
43
44
|
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
44
45
|
|
|
45
|
-
function _writeReject(res, message) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
47
|
+
denyResponse(req, res, {
|
|
48
|
+
onDeny: onDeny,
|
|
49
|
+
problem: problemMode,
|
|
50
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
51
|
+
info: { status: 403, reason: reason },
|
|
52
|
+
problemCode: "fetch-metadata-refused",
|
|
53
|
+
problemTitle: "Forbidden",
|
|
54
|
+
problemDetail: message,
|
|
55
|
+
contentType: "application/json; charset=utf-8",
|
|
56
|
+
body: JSON.stringify({ error: message }),
|
|
51
57
|
});
|
|
52
|
-
res.end(body);
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
/**
|
|
@@ -79,6 +84,8 @@ function _writeReject(res, message) {
|
|
|
79
84
|
* allowedNavigate: boolean, // default true
|
|
80
85
|
* methods: string[], // default POST/PUT/DELETE/PATCH
|
|
81
86
|
* audit: boolean, // default true
|
|
87
|
+
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
88
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
82
89
|
* }
|
|
83
90
|
*
|
|
84
91
|
* @example
|
|
@@ -95,9 +102,11 @@ function create(opts) {
|
|
|
95
102
|
opts = opts || {};
|
|
96
103
|
validateOpts(opts, [
|
|
97
104
|
"allowSameSite", "allowCrossSite", "allowMissing",
|
|
98
|
-
"allowedDest", "allowedNavigate", "methods", "audit",
|
|
105
|
+
"allowedDest", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
|
|
99
106
|
], "middleware.fetchMetadata");
|
|
100
107
|
|
|
108
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
109
|
+
var problemMode = opts.problemDetails === true;
|
|
101
110
|
var allowSameSite = opts.allowSameSite !== false;
|
|
102
111
|
var allowCrossSite = opts.allowCrossSite === true;
|
|
103
112
|
var allowMissing = opts.allowMissing !== false;
|
|
@@ -135,7 +144,7 @@ function create(opts) {
|
|
|
135
144
|
// Defer to other auth/CSRF layers per allowMissing.
|
|
136
145
|
if (!allowMissing) {
|
|
137
146
|
_emitDenied(req, "fetch-metadata-missing");
|
|
138
|
-
return _writeReject(res, "Fetch-metadata required.");
|
|
147
|
+
return _writeReject(req, res, "Fetch-metadata required.", "fetch-metadata-missing", onDeny, problemMode);
|
|
139
148
|
}
|
|
140
149
|
return next();
|
|
141
150
|
}
|
|
@@ -144,7 +153,7 @@ function create(opts) {
|
|
|
144
153
|
if (site === "none") {
|
|
145
154
|
if (allowedNavigate) return next();
|
|
146
155
|
_emitDenied(req, "navigate-disallowed");
|
|
147
|
-
return _writeReject(res, "Direct navigation not allowed for this method.");
|
|
156
|
+
return _writeReject(req, res, "Direct navigation not allowed for this method.", "navigation-not-allowed", onDeny, problemMode);
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
if (site === "same-origin") return next();
|
|
@@ -152,7 +161,7 @@ function create(opts) {
|
|
|
152
161
|
if (site === "same-site") {
|
|
153
162
|
if (allowSameSite) return next();
|
|
154
163
|
_emitDenied(req, "same-site-disallowed");
|
|
155
|
-
return _writeReject(res, "Same-site request not allowed.");
|
|
164
|
+
return _writeReject(req, res, "Same-site request not allowed.", "same-site-not-allowed", onDeny, problemMode);
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
// cross-site
|
|
@@ -164,7 +173,7 @@ function create(opts) {
|
|
|
164
173
|
", dest=" + (dest || "?") + ")");
|
|
165
174
|
try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
|
|
166
175
|
catch (_e) { /* best-effort */ }
|
|
167
|
-
return _writeReject(res, "Cross-site request refused.");
|
|
176
|
+
return _writeReject(req, res, "Cross-site request refused.", "cross-site-refused", onDeny, problemMode);
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
179
|
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
var lazyRequire = require("../lazy-require");
|
|
42
42
|
var requestHelpers = require("../request-helpers");
|
|
43
43
|
var validateOpts = require("../validate-opts");
|
|
44
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
44
45
|
var { defineClass } = require("../framework-error");
|
|
45
46
|
|
|
46
47
|
var HostAllowlistError = defineClass("HostAllowlistError", { alwaysPermanent: true });
|
|
@@ -102,6 +103,8 @@ function _matches(entry, actual) {
|
|
|
102
103
|
* denyStatus: number, // default 421
|
|
103
104
|
* denyBody: string,
|
|
104
105
|
* audit: boolean, // default true
|
|
106
|
+
* onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, host }
|
|
107
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
105
108
|
* }
|
|
106
109
|
*
|
|
107
110
|
* @example
|
|
@@ -114,7 +117,7 @@ function _matches(entry, actual) {
|
|
|
114
117
|
function create(opts) {
|
|
115
118
|
validateOpts.requireObject(opts, "middleware.hostAllowlist", HostAllowlistError);
|
|
116
119
|
validateOpts(opts, [
|
|
117
|
-
"hosts", "denyStatus", "denyBody", "audit",
|
|
120
|
+
"hosts", "denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
|
|
118
121
|
], "middleware.hostAllowlist");
|
|
119
122
|
|
|
120
123
|
if (!Array.isArray(opts.hosts) || opts.hosts.length === 0) {
|
|
@@ -134,6 +137,8 @@ function create(opts) {
|
|
|
134
137
|
var denyStatus = (typeof opts.denyStatus === "number") ? opts.denyStatus : 421; // HTTP 421 status
|
|
135
138
|
var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Misdirected Request";
|
|
136
139
|
var auditOn = opts.audit !== false;
|
|
140
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
141
|
+
var problemMode = opts.problemDetails === true;
|
|
137
142
|
|
|
138
143
|
return function hostAllowlistMiddleware(req, res, next) {
|
|
139
144
|
var raw = req.headers && req.headers.host;
|
|
@@ -141,7 +146,7 @@ function create(opts) {
|
|
|
141
146
|
// RFC 7230 §5.4 — a request without a Host header is malformed
|
|
142
147
|
// for HTTP/1.1; HTTP/2 maps :authority into req.headers.host
|
|
143
148
|
// automatically. Reject either shape.
|
|
144
|
-
_deny(res,
|
|
149
|
+
_deny(req, res, "missing-host", null);
|
|
145
150
|
_emitDenied(req, "missing-host");
|
|
146
151
|
return;
|
|
147
152
|
}
|
|
@@ -151,20 +156,26 @@ function create(opts) {
|
|
|
151
156
|
if (_matches(hosts[hi], actual)) { matched = true; break; }
|
|
152
157
|
}
|
|
153
158
|
if (!matched) {
|
|
154
|
-
_deny(res,
|
|
159
|
+
_deny(req, res, "host-not-in-allowlist", actual);
|
|
155
160
|
_emitDenied(req, "host-not-in-allowlist", actual);
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
158
163
|
return next();
|
|
159
164
|
};
|
|
160
165
|
|
|
161
|
-
function _deny(res,
|
|
166
|
+
function _deny(req, res, reason, host) {
|
|
162
167
|
if (res.headersSent) return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
denyResponse(req, res, {
|
|
169
|
+
onDeny: onDeny,
|
|
170
|
+
problem: problemMode,
|
|
171
|
+
status: denyStatus,
|
|
172
|
+
info: { status: denyStatus, reason: reason, host: host },
|
|
173
|
+
problemCode: "misdirected-request",
|
|
174
|
+
problemTitle: "Misdirected Request",
|
|
175
|
+
problemDetail: "The request Host header is not served by this endpoint.",
|
|
176
|
+
contentType: "text/plain; charset=utf-8",
|
|
177
|
+
body: denyBody,
|
|
166
178
|
});
|
|
167
|
-
res.end(body);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
function _emitDenied(req, reason, actual) {
|
package/lib/middleware/index.js
CHANGED
|
@@ -53,6 +53,7 @@ var requireAal = require("./require-aal");
|
|
|
53
53
|
var requireAuth = require("./require-auth");
|
|
54
54
|
var requireContentType = require("./require-content-type");
|
|
55
55
|
var ageGate = require("./age-gate");
|
|
56
|
+
var requireBoundKey = require("./require-bound-key");
|
|
56
57
|
var requireMethods = require("./require-methods");
|
|
57
58
|
var requireMtls = require("./require-mtls");
|
|
58
59
|
var requireStepUp = require("./require-step-up");
|
|
@@ -85,6 +86,7 @@ module.exports = {
|
|
|
85
86
|
requireAuth: requireAuth.create,
|
|
86
87
|
requireContentType: requireContentType.create,
|
|
87
88
|
ageGate: ageGate.create,
|
|
89
|
+
requireBoundKey: requireBoundKey.create,
|
|
88
90
|
requireMethods: requireMethods.create,
|
|
89
91
|
requireMtls: requireMtls.create,
|
|
90
92
|
requireStepUp: requireStepUp.create,
|
|
@@ -145,6 +147,7 @@ module.exports = {
|
|
|
145
147
|
requireAuth: requireAuth,
|
|
146
148
|
requireContentType: requireContentType,
|
|
147
149
|
ageGate: ageGate,
|
|
150
|
+
requireBoundKey: requireBoundKey,
|
|
148
151
|
requireMethods: requireMethods,
|
|
149
152
|
requireMtls: requireMtls,
|
|
150
153
|
requireStepUp: requireStepUp,
|
|
@@ -48,6 +48,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
48
48
|
var requestHelpers = require("../request-helpers");
|
|
49
49
|
var ssrfGuard = require("../ssrf-guard");
|
|
50
50
|
var validateOpts = require("../validate-opts");
|
|
51
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
51
52
|
var { defineClass } = require("../framework-error");
|
|
52
53
|
|
|
53
54
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
@@ -98,6 +99,8 @@ function _validateCidr(cidr) {
|
|
|
98
99
|
* denyStatus: number, // default 404
|
|
99
100
|
* denyBody: string, // default "Not Found"
|
|
100
101
|
* audit: object,
|
|
102
|
+
* onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, clientIp, route }
|
|
103
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
101
104
|
* }
|
|
102
105
|
*
|
|
103
106
|
* @example
|
|
@@ -113,7 +116,7 @@ function create(opts) {
|
|
|
113
116
|
opts = opts || {};
|
|
114
117
|
validateOpts(opts, [
|
|
115
118
|
"paths", "allowedCidrs", "deniedCidrs", "trustProxy",
|
|
116
|
-
"denyStatus", "denyBody", "audit",
|
|
119
|
+
"denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
|
|
117
120
|
], "middleware.networkAllowlist");
|
|
118
121
|
|
|
119
122
|
if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
|
|
@@ -156,6 +159,23 @@ function create(opts) {
|
|
|
156
159
|
var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Not Found";
|
|
157
160
|
var auditOn = opts.audit !== false && opts.audit != null;
|
|
158
161
|
var auditInstance = opts.audit === true ? null : opts.audit; // null → use lazy-required default
|
|
162
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
163
|
+
var problemMode = opts.problemDetails === true;
|
|
164
|
+
|
|
165
|
+
function _deny(req, res, ip, route) {
|
|
166
|
+
_emitDeny(req, ip, route);
|
|
167
|
+
denyResponse(req, res, {
|
|
168
|
+
onDeny: onDeny,
|
|
169
|
+
problem: problemMode,
|
|
170
|
+
status: denyStatus,
|
|
171
|
+
info: { status: denyStatus, reason: "ip-not-in-allowlist", clientIp: ip, route: route },
|
|
172
|
+
problemCode: "network-gate-denied",
|
|
173
|
+
problemTitle: denyBody,
|
|
174
|
+
problemDetail: "Access to this resource is restricted by network policy.",
|
|
175
|
+
contentType: "text/plain",
|
|
176
|
+
body: denyBody,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
159
179
|
|
|
160
180
|
function _emitDeny(req, ip, route) {
|
|
161
181
|
if (!auditOn) return;
|
|
@@ -196,9 +216,7 @@ function create(opts) {
|
|
|
196
216
|
if (!ip) {
|
|
197
217
|
// Fail closed: a request we can't even derive an IP for shouldn't
|
|
198
218
|
// bypass the gate.
|
|
199
|
-
|
|
200
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
201
|
-
res.end(denyBody);
|
|
219
|
+
_deny(req, res, "<unknown>", pathname);
|
|
202
220
|
return;
|
|
203
221
|
}
|
|
204
222
|
|
|
@@ -208,9 +226,7 @@ function create(opts) {
|
|
|
208
226
|
for (var dii = 0; dii < deniedCidrs.length; dii++) {
|
|
209
227
|
try {
|
|
210
228
|
if (ssrfGuard.cidrContains(deniedCidrs[dii], ip)) {
|
|
211
|
-
|
|
212
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
213
|
-
res.end(denyBody);
|
|
229
|
+
_deny(req, res, ip, pathname);
|
|
214
230
|
return;
|
|
215
231
|
}
|
|
216
232
|
} catch (_e) { /* skip malformed at runtime — caught at config */ }
|
|
@@ -222,9 +238,7 @@ function create(opts) {
|
|
|
222
238
|
} catch (_e) { /* skip malformed at runtime — caught at config */ }
|
|
223
239
|
}
|
|
224
240
|
if (!allowed) {
|
|
225
|
-
|
|
226
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
227
|
-
res.end(denyBody);
|
|
241
|
+
_deny(req, res, ip, pathname);
|
|
228
242
|
return;
|
|
229
243
|
}
|
|
230
244
|
return next();
|
|
@@ -62,6 +62,7 @@ var requestHelpers = require("../request-helpers");
|
|
|
62
62
|
var safeAsync = require("../safe-async");
|
|
63
63
|
var validateOpts = require("../validate-opts");
|
|
64
64
|
var clusterStorage = require("../cluster-storage");
|
|
65
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
65
66
|
|
|
66
67
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
67
68
|
var logger = lazyRequire(function () { return require("../log").boot("rate-limit"); });
|
|
@@ -363,6 +364,8 @@ function _resolveBackend(opts) {
|
|
|
363
364
|
* keyFn: function(req): string,
|
|
364
365
|
* statusOnLimit: number, // default 429
|
|
365
366
|
* bodyOnLimit: string, // default "Too Many Requests"
|
|
367
|
+
* onDeny: function(req, res, info): void, // own the refusal response; info = { status, reason, limit, remaining, retryAfter, key }
|
|
368
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
366
369
|
* header: boolean, // default true
|
|
367
370
|
* headerPrefix: string, // default "X-RateLimit-" — builds <prefix>Limit / <prefix>Remaining (e.g. "RateLimit-" for the IETF draft names)
|
|
368
371
|
* skipPaths: Array<string|RegExp>,
|
|
@@ -391,7 +394,8 @@ function _resolveBackend(opts) {
|
|
|
391
394
|
function create(opts) {
|
|
392
395
|
opts = opts || {};
|
|
393
396
|
validateOpts(opts, [
|
|
394
|
-
"keyFn", "statusOnLimit", "bodyOnLimit", "
|
|
397
|
+
"keyFn", "statusOnLimit", "bodyOnLimit", "onDeny", "problemDetails",
|
|
398
|
+
"header", "headerPrefix", "skipPaths", "scope",
|
|
395
399
|
"backend", "trustProxy", "algorithm",
|
|
396
400
|
// memory backend (token-bucket)
|
|
397
401
|
"burst", "refillPerSecond",
|
|
@@ -404,6 +408,8 @@ function create(opts) {
|
|
|
404
408
|
var keyFn = opts.keyFn || _clientIp;
|
|
405
409
|
var statusOnLimit = opts.statusOnLimit || 429;
|
|
406
410
|
var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
|
|
411
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
412
|
+
var problemMode = opts.problemDetails === true;
|
|
407
413
|
var emitHeaders = opts.header !== false;
|
|
408
414
|
// headerPrefix (default "X-RateLimit-") builds the limit/remaining header
|
|
409
415
|
// names as <prefix>Limit / <prefix>Remaining. The X-RateLimit-* family is a
|
|
@@ -455,10 +461,21 @@ function create(opts) {
|
|
|
455
461
|
requestId: req.requestId,
|
|
456
462
|
});
|
|
457
463
|
} catch (_e) { /* audit best-effort */ }
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
464
|
+
var retryAfter = verdict.retryAfter > 0 ? verdict.retryAfter : null;
|
|
465
|
+
denyResponse(req, res, {
|
|
466
|
+
onDeny: onDeny,
|
|
467
|
+
problem: problemMode,
|
|
468
|
+
status: statusOnLimit,
|
|
469
|
+
info: { status: statusOnLimit, reason: "rate-limit-exceeded",
|
|
470
|
+
limit: verdict.limit, remaining: verdict.remaining,
|
|
471
|
+
retryAfter: verdict.retryAfter, key: k },
|
|
472
|
+
problemCode: "rate-limit-exceeded",
|
|
473
|
+
problemTitle: "Too Many Requests",
|
|
474
|
+
problemDetail: "Request rate limit exceeded; retry after the indicated interval.",
|
|
475
|
+
problemExt: retryAfter !== null ? { retryAfter: retryAfter } : null,
|
|
476
|
+
contentType: "text/plain",
|
|
477
|
+
body: bodyOnLimit,
|
|
478
|
+
});
|
|
462
479
|
}
|
|
463
480
|
|
|
464
481
|
var middleware = function rateLimit(req, res, next) {
|