@blamejs/core 0.14.4 → 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 +4 -0
- package/README.md +1 -0
- package/lib/a2a-tasks.js +6 -6
- package/lib/ai-input.js +1 -1
- package/lib/auth/sd-jwt-vc.js +1 -1
- package/lib/calendar.js +6 -6
- package/lib/content-credentials.js +2 -2
- package/lib/cra-report.js +3 -3
- package/lib/guard-cidr.js +1 -1
- package/lib/http-client-cache.js +1 -1
- package/lib/mail-auth.js +1 -1
- package/lib/mail-crypto-smime.js +1 -1
- package/lib/mail-deploy.js +1 -1
- package/lib/mail-dkim.js +1 -1
- package/lib/mail-server-jmap.js +2 -2
- package/lib/mcp.js +6 -6
- 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/compose-pipeline.js +1 -1
- 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/lib/safe-decompress.js +1 -1
- package/lib/safe-url.js +1 -1
- package/lib/stream-throttle.js +2 -2
- package/lib/websocket.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/middleware/cors.js
CHANGED
|
@@ -55,6 +55,7 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
55
55
|
var requestHelpers = require("../request-helpers");
|
|
56
56
|
var safeUrl = require("../safe-url");
|
|
57
57
|
var validateOpts = require("../validate-opts");
|
|
58
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
58
59
|
var { defineClass } = require("../framework-error");
|
|
59
60
|
|
|
60
61
|
// CORS audit events use the proxy-aware client IP only when the
|
|
@@ -185,6 +186,8 @@ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, str
|
|
|
185
186
|
* refuseUnknown: boolean, // default true
|
|
186
187
|
* strictNullOrigin: boolean, // default true
|
|
187
188
|
* trustProxy: boolean|number,
|
|
189
|
+
* onDeny: function(req, res, info): void, // own every refusal; info = { status, reason, origin, header? }
|
|
190
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
188
191
|
* }
|
|
189
192
|
*
|
|
190
193
|
* @example
|
|
@@ -202,7 +205,7 @@ function create(opts) {
|
|
|
202
205
|
validateOpts(opts, [
|
|
203
206
|
"origins", "siteOrigin", "methods", "headers", "exposeHeaders",
|
|
204
207
|
"credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
|
|
205
|
-
"strictNullOrigin", "allowPrivateNetwork",
|
|
208
|
+
"strictNullOrigin", "allowPrivateNetwork", "onDeny", "problemDetails",
|
|
206
209
|
], "middleware.cors");
|
|
207
210
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
208
211
|
? opts.trustProxy : false;
|
|
@@ -269,6 +272,22 @@ function create(opts) {
|
|
|
269
272
|
// header). Operators with a no-referrer page producing legitimate
|
|
270
273
|
// Origin: null on same-origin POSTs flip to false explicitly.
|
|
271
274
|
var strictNullOrigin = opts.strictNullOrigin !== false;
|
|
275
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
276
|
+
var problemMode = opts.problemDetails === true;
|
|
277
|
+
|
|
278
|
+
function _refuse(req, res, reason, body, ext) {
|
|
279
|
+
denyResponse(req, res, {
|
|
280
|
+
onDeny: onDeny,
|
|
281
|
+
problem: problemMode,
|
|
282
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
283
|
+
info: Object.assign({ status: 403, reason: reason }, ext || {}),
|
|
284
|
+
problemCode: "cors-refused",
|
|
285
|
+
problemTitle: "Forbidden",
|
|
286
|
+
problemDetail: body,
|
|
287
|
+
contentType: "text/plain",
|
|
288
|
+
body: body,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
272
291
|
|
|
273
292
|
return function cors(req, res, next) {
|
|
274
293
|
var origin = req.headers && req.headers.origin;
|
|
@@ -302,9 +321,8 @@ function create(opts) {
|
|
|
302
321
|
requestId: req.requestId,
|
|
303
322
|
});
|
|
304
323
|
} catch (_e) { /* audit best-effort */ }
|
|
305
|
-
if (typeof res.writeHead === "function") {
|
|
306
|
-
|
|
307
|
-
res.end("CORS: origin not allowed");
|
|
324
|
+
if (typeof res.writeHead === "function" || onDeny) {
|
|
325
|
+
_refuse(req, res, "origin-not-allowed", "CORS: origin not allowed", { origin: origin });
|
|
308
326
|
return;
|
|
309
327
|
}
|
|
310
328
|
}
|
|
@@ -333,10 +351,9 @@ function create(opts) {
|
|
|
333
351
|
var asked = requestHelpers.parseListHeader(requestedHdrs, { lowercase: true });
|
|
334
352
|
for (var ah = 0; ah < asked.length; ah++) {
|
|
335
353
|
if (allowedHeadersSet.indexOf(asked[ah]) === -1) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
354
|
+
_refuse(req, res, "requested-header-not-allowed",
|
|
355
|
+
"CORS: requested header '" + asked[ah] + "' not in allow-list",
|
|
356
|
+
{ origin: origin, header: asked[ah] });
|
|
340
357
|
return;
|
|
341
358
|
}
|
|
342
359
|
}
|
|
@@ -357,10 +374,9 @@ function create(opts) {
|
|
|
357
374
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
358
375
|
}
|
|
359
376
|
} else {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
377
|
+
_refuse(req, res, "private-network-not-permitted",
|
|
378
|
+
"CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)",
|
|
379
|
+
{ origin: origin });
|
|
364
380
|
return;
|
|
365
381
|
}
|
|
366
382
|
}
|
|
@@ -70,6 +70,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
70
70
|
var forms = require("../forms");
|
|
71
71
|
var requestHelpers = require("../request-helpers");
|
|
72
72
|
var validateOpts = require("../validate-opts");
|
|
73
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
73
74
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
74
75
|
|
|
75
76
|
var DEFAULT_FIELD_NAME = "_csrf";
|
|
@@ -218,15 +219,18 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
|
|
|
218
219
|
return null;
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
function _writeReject(res, message) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
223
|
+
denyResponse(req, res, {
|
|
224
|
+
onDeny: onDeny,
|
|
225
|
+
problem: problemMode,
|
|
226
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
227
|
+
info: { status: 403, reason: reason },
|
|
228
|
+
problemCode: "csrf-refused",
|
|
229
|
+
problemTitle: "Forbidden",
|
|
230
|
+
problemDetail: message,
|
|
231
|
+
contentType: "application/json; charset=utf-8",
|
|
232
|
+
body: JSON.stringify({ error: message }),
|
|
233
|
+
});
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
/**
|
|
@@ -262,6 +266,8 @@ function _writeReject(res, message) {
|
|
|
262
266
|
* trustProxy: boolean|number,
|
|
263
267
|
* audit: boolean,
|
|
264
268
|
* skipStateless: boolean, // default false — skip validation for Authorization-header / cookieless (not-CSRF-able) requests
|
|
269
|
+
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
270
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
265
271
|
* }
|
|
266
272
|
*
|
|
267
273
|
* @example
|
|
@@ -279,8 +285,10 @@ function create(opts) {
|
|
|
279
285
|
validateOpts(opts, [
|
|
280
286
|
"cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
|
|
281
287
|
"trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
|
|
282
|
-
"requireOrigin", "skipStateless",
|
|
288
|
+
"requireOrigin", "skipStateless", "onDeny", "problemDetails",
|
|
283
289
|
], "middleware.csrfProtect");
|
|
290
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
291
|
+
var problemMode = opts.problemDetails === true;
|
|
284
292
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
285
293
|
? opts.trustProxy : false;
|
|
286
294
|
var _isHttps = _isHttpsFor(trustProxy);
|
|
@@ -477,7 +485,7 @@ function create(opts) {
|
|
|
477
485
|
var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
|
|
478
486
|
if (bare !== "application/json") {
|
|
479
487
|
_emitDenied(req, "non-JSON content-type: " + (bare || "<absent>"));
|
|
480
|
-
return _writeReject(res, "CSRF: state-changing requests require Content-Type: application/json.");
|
|
488
|
+
return _writeReject(req, res, "CSRF: state-changing requests require Content-Type: application/json.", "content-type-required", onDeny, problemMode);
|
|
481
489
|
}
|
|
482
490
|
}
|
|
483
491
|
|
|
@@ -489,7 +497,7 @@ function create(opts) {
|
|
|
489
497
|
var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps, requireOriginOpt);
|
|
490
498
|
if (originReason !== null) {
|
|
491
499
|
_emitDenied(req, "origin/referer: " + originReason);
|
|
492
|
-
return _writeReject(res, "CSRF cross-origin request refused.");
|
|
500
|
+
return _writeReject(req, res, "CSRF cross-origin request refused.", "cross-origin-refused", onDeny, problemMode);
|
|
493
501
|
}
|
|
494
502
|
}
|
|
495
503
|
|
|
@@ -499,7 +507,7 @@ function create(opts) {
|
|
|
499
507
|
}
|
|
500
508
|
if (!expected) {
|
|
501
509
|
_emitDenied(req, cookieCfg ? "no token cookie issued yet" : "no expected token in session");
|
|
502
|
-
return _writeReject(res, "CSRF token mismatch.");
|
|
510
|
+
return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
|
|
503
511
|
}
|
|
504
512
|
|
|
505
513
|
// Header path first — covers JSON / AJAX / multipart cases.
|
|
@@ -517,7 +525,7 @@ function create(opts) {
|
|
|
517
525
|
|
|
518
526
|
if (!forms.verifyCsrfToken(submitted || "", expected)) {
|
|
519
527
|
_emitDenied(req, "submitted token does not match expected");
|
|
520
|
-
return _writeReject(res, "CSRF token mismatch.");
|
|
528
|
+
return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
|
|
521
529
|
}
|
|
522
530
|
|
|
523
531
|
return next();
|
|
@@ -41,6 +41,7 @@ var defineClass = require("../framework-error").defineClass;
|
|
|
41
41
|
var lazyRequire = require("../lazy-require");
|
|
42
42
|
var networkByteQuota = require("../network-byte-quota");
|
|
43
43
|
var validateOpts = require("../validate-opts");
|
|
44
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
44
45
|
|
|
45
46
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
46
47
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
@@ -76,7 +77,9 @@ function _defaultGetKey(req) {
|
|
|
76
77
|
* bytesPerDay: number, // required, positive, finite
|
|
77
78
|
* getKey: function(req): string|null, // default: req client IP
|
|
78
79
|
* cache: object, // null = in-memory single-node
|
|
79
|
-
*
|
|
80
|
+
* onDeny: function(req, res, info): void, // own the 429; info = { status, reason, quota, total, retryAfterSec }
|
|
81
|
+
* onExceeded: function(req, res, info): void, // legacy alias for onDeny
|
|
82
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
80
83
|
* skipPaths: string[],
|
|
81
84
|
* now: function(): number,
|
|
82
85
|
* audit: boolean, // default true
|
|
@@ -94,7 +97,7 @@ function create(opts) {
|
|
|
94
97
|
opts = opts || {};
|
|
95
98
|
validateOpts(opts, [
|
|
96
99
|
"bytesPerDay", "cache", "getKey", "audit",
|
|
97
|
-
"onExceeded", "skipPaths", "now",
|
|
100
|
+
"onDeny", "onExceeded", "problemDetails", "skipPaths", "now",
|
|
98
101
|
], "middleware.dailyByteQuota");
|
|
99
102
|
|
|
100
103
|
if (typeof opts.bytesPerDay !== "number" || !isFinite(opts.bytesPerDay) || opts.bytesPerDay <= 0) {
|
|
@@ -105,7 +108,11 @@ function create(opts) {
|
|
|
105
108
|
var bytesPerDay = opts.bytesPerDay;
|
|
106
109
|
var getKey = typeof opts.getKey === "function" ? opts.getKey : _defaultGetKey;
|
|
107
110
|
var auditOn = opts.audit !== false;
|
|
108
|
-
|
|
111
|
+
// onDeny is the canonical hook across the deny-path middleware
|
|
112
|
+
// family; onExceeded is the original name kept working as an alias.
|
|
113
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny
|
|
114
|
+
: (typeof opts.onExceeded === "function" ? opts.onExceeded : null);
|
|
115
|
+
var problemMode = opts.problemDetails === true;
|
|
109
116
|
var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
|
|
110
117
|
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
111
118
|
|
|
@@ -169,22 +176,29 @@ function create(opts) {
|
|
|
169
176
|
_emitMetric("refused", 1, { reason: "quota-exceeded" });
|
|
170
177
|
_emitAudit("refused", "denied", { key: key, total: total, quota: bytesPerDay });
|
|
171
178
|
var info = {
|
|
179
|
+
status: 429,
|
|
180
|
+
reason: "quota-exceeded",
|
|
172
181
|
quota: bytesPerDay,
|
|
173
182
|
total: total,
|
|
174
183
|
retryAfterSec: Math.ceil(C.TIME.hours(1) / C.TIME.seconds(1)),
|
|
175
184
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
denyResponse(req, res, {
|
|
186
|
+
onDeny: onDeny,
|
|
187
|
+
problem: problemMode,
|
|
188
|
+
status: 429,
|
|
189
|
+
info: info,
|
|
190
|
+
problemCode: "daily-byte-quota-exceeded",
|
|
191
|
+
problemTitle: "Too Many Requests",
|
|
192
|
+
problemDetail: "Daily byte quota exceeded; retry after the indicated interval.",
|
|
193
|
+
problemExt: { quota: bytesPerDay, total: total, retryAfter: info.retryAfterSec },
|
|
194
|
+
headers: {
|
|
183
195
|
"Retry-After": String(info.retryAfterSec),
|
|
184
196
|
"Cache-Control": "no-store",
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
},
|
|
198
|
+
contentType: "application/json; charset=utf-8",
|
|
199
|
+
body: JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }),
|
|
200
|
+
onThrow: function (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); },
|
|
201
|
+
});
|
|
188
202
|
return;
|
|
189
203
|
}
|
|
190
204
|
|
|
@@ -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
|
|