@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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.6 (2026-05-30) — **Access-refusal middleware can return RFC 9457 problem+json or a custom response, and several documented-but-uncallable APIs are now reachable.** Every access-refusal middleware — the auth gates (bearer, DPoP, mTLS, AAL, bound-key), CSRF, CORS, rate-limit, bot-guard, age-gate, the host and network allowlists, and the method and content-type gates — now accepts two uniform options: `problemDetails: true` returns an RFC 9457 `application/problem+json` body, and `onDeny(req, res, info)` hands the response to the caller. With neither set the refusal is byte-for-byte what it was, so this is a drop-in change that lets a service standardize one error envelope across its API instead of working around each middleware's hardcoded body. Alongside that: `b.middleware.requireBoundKey` is now exported (it was documented and tested but never wired into the middleware surface), `b.middleware.bearerAuth` accepts `requiredScopes` (previously rejected at construction, which made its scope-enforcement path unreachable), API-key refusals send the RFC 6750 challenge code that matches the failure, two documented call paths that named a missing namespace segment are corrected, and the release flow now flags stale GitHub Actions and vendored bundles — with a ready-to-paste pin — before a dependency PR is needed. **Added:** *Uniform `onDeny` and `problemDetails` options on every access-refusal middleware* — Each request-lifecycle middleware that refuses a request now takes `problemDetails: true` to emit an RFC 9457 `application/problem+json` body (composing `b.problemDetails`) and `onDeny(req, res, info)` to take over the response entirely; `info` carries the status, a machine reason, and the middleware-specific fields. The deny-path response headers (`Allow`, `WWW-Authenticate`, `Retry-After`, `Accept`) survive every mode. When neither option is set the response is unchanged. Covers `requireAuth`, `requireAal`, `requireMethods`, `requireContentType`, `requireMtls`, `requireBoundKey`, `bearerAuth`, `dpop`, `csrfProtect`, `fetchMetadata`, `botGuard`, `ageGate`, `hostAllowlist`, `networkAllowlist`, `cors`, `rateLimit`, and `dailyByteQuota` (whose existing `onExceeded` keeps working as an alias of `onDeny`). **Fixed:** *`b.middleware.requireBoundKey` is now callable* — The Bearer-API-key middleware was documented (with examples and tests) but never exported on `b.middleware`, so `b.middleware.requireBoundKey(...)` threw `undefined is not a function`. It is now wired into the middleware surface. · *`b.middleware.bearerAuth` accepts `requiredScopes`* — The RFC 6750 scope-enforcement path read `opts.requiredScopes`, but the option was rejected at construction with `unknown option`, making the 403 `insufficient_scope` behavior unreachable. `requiredScopes` is now an accepted option. · *RFC 6750 challenge codes on API-key refusals* — `b.middleware.requireBoundKey` now sends the `WWW-Authenticate` error code that matches the failure: `insufficient_scope` on a 403 missing-scope, `invalid_token` on an unknown or revoked token, and no error code on a 401 that presented no credentials (RFC 6750 §3). It previously sent `invalid_request` for every refusal. · *Corrected two documented call paths* — The compliance and network references named a path that dropped a namespace segment: the conformity-assessment scaffold is at `b.cra.report.conformityAssessment` (not `b.cra.conformityAssessment`), and the per-socket tuning helper is at `b.network.socket.applyToSocket` (not `b.network.applyToSocket`). The documented signatures now match the callable paths. · *GitHub Actions pins refreshed* — `github/codeql-action` 4.35.5 to 4.36.0, and `docker/login-action`, `docker/setup-buildx-action`, and `docker/setup-qemu-action` to their latest releases. **Detectors:** *`@primitive` reachability gate* — A new check resolves every documented `b.X.Y` primitive against the actual public surface and fails the build when a documented path is not callable (factory-instance shorthands excluded). This is the gate that would have caught the `requireBoundKey` and call-path issues above. · *Deny-path composition gate* — A new check requires every access-refusal middleware to route its refusal through the shared deny-response writer, so a future middleware cannot reintroduce a hardcoded body that locks callers out of `onDeny` / `problemDetails`. · *Actions and vendor currency in the release flow* — The release flow now fails the cut when a SHA-pinned GitHub Action or a vendored bundle is behind its latest upstream release. The actions report prints a ready-to-paste `owner/repo@<sha> # vX.Y.Z` pin and every file and line that uses it, so the bump is copy-paste rather than an after-the-fact dependency PR. Transient registry or API errors stay advisory so a flaky network response does not block an unrelated release.
|
|
12
|
+
|
|
11
13
|
- v0.14.5 (2026-05-30) — **Finished cleaning up the mislabeled byte-literal lint suppressions, with no API or behavior changes.** A follow-up to the byte-literal lint tightening. The remaining suppression comments that named the byte-literal check on values that are not byte sizes — JSON-RPC error codes, HTTP status codes, octet ranges, day-in-milliseconds constants — are removed, keeping their explanatory text and any correctly-named companion suppression. Every byte-literal suppression that remains is now on genuine 1024-scale byte arithmetic. Source-comment hygiene only. **Changed:** *Remaining mislabeled byte-literal suppressions removed* — The byte-literal lint was previously a check on any multiple-of-8 integer, so suppression comments naming it were scattered across non-byte values. The last of those (in a handful of files, in mixed comment formats) are now removed — their explanatory text is retained as plain comments, and any correctly-named companion suppression is kept. The only byte-literal suppressions that remain are on genuine 1024-scale byte arithmetic. No change to any exported API, error code, wire format, or runtime behavior.
|
|
12
14
|
|
|
13
15
|
- v0.14.4 (2026-05-30) — **Removed three pieces of dead code from the SAML, TLS, and JMAP surfaces; no API or behavior changes.** Cleanup of unreachable code. A reverse signature-algorithm lookup in the SAML verifier was never called — the actual verification path resolves the algorithm through the supported-signature table — so it is removed and a stale comment that referenced it is corrected. A leftover no-op placeholder in the TLS certificate re-encode path (a zero-length slice that was assigned and discarded) is removed, leaving the verbatim extension re-encode it sat next to. An unused JMAP well-known-path constant that existed only to be discarded is removed. None of this changes any exported API, error code, wire format, or runtime behavior. **Removed:** *Unreachable code in SAML, TLS, and JMAP* — Removed `_sigAlgFromUri` from the SAML module (a reverse alg lookup that was never called — the embedded XML-DSig verifier resolves the algorithm via the supported-signature table, and the redirect-binding path uses the forward `_sigAlgUrn`), a discarded zero-length-slice placeholder in the TLS certificate extension re-encode path, and an unused well-known-path constant in the JMAP server. Internal cleanup only — no change to any exported API, error code, wire format, or runtime behavior.
|
package/README.md
CHANGED
|
@@ -129,6 +129,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
129
129
|
- CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
|
|
130
130
|
- CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
|
|
131
131
|
- `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
|
|
132
|
+
- Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
|
|
132
133
|
- **Additional middleware** to mount in your `routes` callback: compression, SSE, request logging, request-time DB role binding (`b.middleware.dbRoleFor`), in-process CIDR fence (`b.middleware.networkAllowlist`)
|
|
133
134
|
- **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
|
|
134
135
|
- **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
|
package/lib/cra-report.js
CHANGED
|
@@ -190,8 +190,8 @@ function create(opts) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* @primitive b.cra.conformityAssessment
|
|
194
|
-
* @signature b.cra.conformityAssessment(opts)
|
|
193
|
+
* @primitive b.cra.report.conformityAssessment
|
|
194
|
+
* @signature b.cra.report.conformityAssessment(opts)
|
|
195
195
|
* @since 0.8.77
|
|
196
196
|
*
|
|
197
197
|
* EU Cyber Resilience Act (Regulation 2024/2847) — Annex VIII
|
|
@@ -223,7 +223,7 @@ function create(opts) {
|
|
|
223
223
|
* }
|
|
224
224
|
*
|
|
225
225
|
* @example
|
|
226
|
-
* var dossier = b.cra.conformityAssessment({
|
|
226
|
+
* var dossier = b.cra.report.conformityAssessment({
|
|
227
227
|
* manufacturer: { name: "Acme Inc.", address: "1 St", contact: "ce@acme.example" },
|
|
228
228
|
* product: { name: "Widget Pro", identifier: "WID-001", version: "1.0", description: "..." },
|
|
229
229
|
* classification: "default",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
var defineClass = require("../framework-error").defineClass;
|
|
39
39
|
var lazyRequire = require("../lazy-require");
|
|
40
40
|
var validateOpts = require("../validate-opts");
|
|
41
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
41
42
|
|
|
42
43
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
43
44
|
|
|
@@ -72,6 +73,8 @@ var AgeGateError = defineClass("AgeGateError", { alwaysPermanent: true });
|
|
|
72
73
|
* errorMessage: string,
|
|
73
74
|
* privacyPostureHeader: string, // default "X-Privacy-Posture"; null/false to suppress
|
|
74
75
|
* audit: boolean, // default true
|
|
76
|
+
* onDeny: function(req, res, info): void, // own the 451; info = { status, reason, age, classification, requireAge }
|
|
77
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
75
78
|
* }
|
|
76
79
|
*
|
|
77
80
|
* @example
|
|
@@ -89,6 +92,7 @@ function create(opts) {
|
|
|
89
92
|
validateOpts(opts, [
|
|
90
93
|
"audit", "getAge", "requireAge", "consentRequired",
|
|
91
94
|
"hasParentalConsent", "skipPaths", "errorMessage", "privacyPostureHeader",
|
|
95
|
+
"onDeny", "problemDetails",
|
|
92
96
|
], "middleware.ageGate");
|
|
93
97
|
|
|
94
98
|
if (typeof opts.getAge !== "function") {
|
|
@@ -105,6 +109,8 @@ function create(opts) {
|
|
|
105
109
|
var auditOn = opts.audit !== false;
|
|
106
110
|
var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
|
|
107
111
|
? opts.errorMessage : "service unavailable without parental consent";
|
|
112
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
113
|
+
var problemMode = opts.problemDetails === true;
|
|
108
114
|
// privacyPostureHeader (default "X-Privacy-Posture") names the response
|
|
109
115
|
// header carrying the below-threshold classification. Pass null/false to
|
|
110
116
|
// suppress it, or a string to rename it for a downstream convention.
|
|
@@ -168,13 +174,20 @@ function create(opts) {
|
|
|
168
174
|
var hasConsent = hasParentalConsent ? !!hasParentalConsent(req) : false;
|
|
169
175
|
if (!hasConsent) {
|
|
170
176
|
_emitAudit("refused", "denied", { age: age, classification: classification, requireAge: requireAge });
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
denyResponse(req, res, {
|
|
178
|
+
onDeny: onDeny,
|
|
179
|
+
problem: problemMode,
|
|
180
|
+
status: 451, // HTTP 451 Unavailable For Legal Reasons
|
|
181
|
+
info: { status: 451, reason: "parental-consent-required",
|
|
182
|
+
age: age, classification: classification, requireAge: requireAge },
|
|
183
|
+
problemCode: "parental-consent-required",
|
|
184
|
+
problemTitle: "Unavailable For Legal Reasons",
|
|
185
|
+
problemDetail: errorMessage,
|
|
186
|
+
problemExt: { requireAge: requireAge, parentalConsent: false },
|
|
187
|
+
headers: { "Cache-Control": "no-store, private" },
|
|
188
|
+
contentType: "application/json; charset=utf-8",
|
|
189
|
+
body: JSON.stringify({ error: errorMessage, requireAge: requireAge, parentalConsent: false }),
|
|
190
|
+
});
|
|
178
191
|
return;
|
|
179
192
|
}
|
|
180
193
|
}
|
|
@@ -37,21 +37,34 @@
|
|
|
37
37
|
var lazyRequire = require("../lazy-require");
|
|
38
38
|
var requestHelpers = require("../request-helpers");
|
|
39
39
|
var validateOpts = require("../validate-opts");
|
|
40
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
40
41
|
var { AuthError } = require("../framework-error");
|
|
41
42
|
|
|
42
43
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
43
44
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
// Shared 401/403 refusal writer for every bearer-auth deny path —
|
|
47
|
+
// routes through denyResponse so a consumer can override via onDeny
|
|
48
|
+
// or emit RFC 9457 application/problem+json via problemDetails.
|
|
49
|
+
function _refuse(req, res, status, challenge, bodyObj, reason, problemExt, onDeny, problemMode) {
|
|
50
|
+
denyResponse(req, res, {
|
|
51
|
+
onDeny: onDeny,
|
|
52
|
+
problem: problemMode,
|
|
53
|
+
status: status,
|
|
54
|
+
info: Object.assign({ status: status, reason: reason }, problemExt || {}),
|
|
55
|
+
problemCode: "bearer-" + reason,
|
|
56
|
+
problemTitle: status === 403 ? "Forbidden" : "Unauthorized",
|
|
57
|
+
problemDetail: typeof bodyObj.error === "string" ? bodyObj.error : ("bearer authentication failed: " + reason),
|
|
58
|
+
problemExt: problemExt || null,
|
|
59
|
+
headers: { "WWW-Authenticate": challenge },
|
|
60
|
+
contentType: "application/json; charset=utf-8",
|
|
61
|
+
body: JSON.stringify(bodyObj),
|
|
53
62
|
});
|
|
54
|
-
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _writeUnauthorized(req, res, scheme, message, realm, onDeny, problemMode) {
|
|
66
|
+
var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
|
|
67
|
+
_refuse(req, res, 401, challenge, { error: message }, "unauthorized", null, onDeny, problemMode);
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
// Three-state extractor: { state: "absent" } when no Authorization
|
|
@@ -102,10 +115,13 @@ function _extractToken(req, scheme) {
|
|
|
102
115
|
* verify: async function(token): user|null, // required
|
|
103
116
|
* scheme: string, // default "Bearer"; some ops use "Token"
|
|
104
117
|
* realm: string,
|
|
118
|
+
* requiredScopes: string[], // RFC 6750 §3 — refuse 403 insufficient_scope when the verified token lacks one
|
|
105
119
|
* errorMessage: string,
|
|
106
120
|
* tokenAttachKey: string,
|
|
107
121
|
* userAttachKey: string,
|
|
108
122
|
* audit: boolean, // default true
|
|
123
|
+
* onDeny: function(req, res, info): void, // own the 401/403; info = { status, reason, ... }
|
|
124
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
109
125
|
* }
|
|
110
126
|
*
|
|
111
127
|
* @example
|
|
@@ -122,7 +138,7 @@ function create(opts) {
|
|
|
122
138
|
opts = opts || {};
|
|
123
139
|
validateOpts(opts, [
|
|
124
140
|
"verify", "audit", "scheme", "errorMessage", "realm",
|
|
125
|
-
"tokenAttachKey", "userAttachKey",
|
|
141
|
+
"tokenAttachKey", "userAttachKey", "requiredScopes", "onDeny", "problemDetails",
|
|
126
142
|
], "middleware.bearerAuth");
|
|
127
143
|
|
|
128
144
|
if (typeof opts.verify !== "function") {
|
|
@@ -131,6 +147,8 @@ function create(opts) {
|
|
|
131
147
|
"the verification path (b.apiKey.verify / b.auth.jwt.verifyExternal / custom)");
|
|
132
148
|
}
|
|
133
149
|
var auditOn = opts.audit !== false;
|
|
150
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
151
|
+
var problemMode = opts.problemDetails === true;
|
|
134
152
|
var scheme = opts.scheme || "Bearer";
|
|
135
153
|
var errorMessage = opts.errorMessage || "Bearer token required.";
|
|
136
154
|
var realm = opts.realm || null;
|
|
@@ -197,13 +215,8 @@ function create(opts) {
|
|
|
197
215
|
if (!res.headersSent) {
|
|
198
216
|
var malformedChallenge = scheme + ' error="invalid_request"' +
|
|
199
217
|
(realm ? ', realm="' + realm + '"' : "");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
203
|
-
"Content-Length": Buffer.byteLength(malformedBody),
|
|
204
|
-
"WWW-Authenticate": malformedChallenge,
|
|
205
|
-
});
|
|
206
|
-
res.end(malformedBody);
|
|
218
|
+
_refuse(req, res, 401, malformedChallenge, { error: errorMessage }, // HTTP 401 status
|
|
219
|
+
"malformed-authorization", null, onDeny, problemMode);
|
|
207
220
|
}
|
|
208
221
|
return;
|
|
209
222
|
}
|
|
@@ -221,13 +234,8 @@ function create(opts) {
|
|
|
221
234
|
var challenge = scheme + ' error="invalid_token"' +
|
|
222
235
|
(realm ? ', realm="' + realm + '"' : "");
|
|
223
236
|
if (!res.headersSent) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
227
|
-
"Content-Length": Buffer.byteLength(body),
|
|
228
|
-
"WWW-Authenticate": challenge,
|
|
229
|
-
});
|
|
230
|
-
res.end(body);
|
|
237
|
+
_refuse(req, res, 401, challenge, { error: errorMessage }, // HTTP 401 status
|
|
238
|
+
"invalid-token", null, onDeny, problemMode);
|
|
231
239
|
}
|
|
232
240
|
return;
|
|
233
241
|
}
|
|
@@ -235,7 +243,7 @@ function create(opts) {
|
|
|
235
243
|
if (!user) {
|
|
236
244
|
_emitAudit("auth.bearer.failure", "failure", req, "verifier-returned-null");
|
|
237
245
|
_emitObs("auth.bearer.rejected", 1, { reason: "verifier-null" });
|
|
238
|
-
_writeUnauthorized(res, scheme, errorMessage, realm);
|
|
246
|
+
_writeUnauthorized(req, res, scheme, errorMessage, realm, onDeny, problemMode);
|
|
239
247
|
return;
|
|
240
248
|
}
|
|
241
249
|
|
|
@@ -260,16 +268,9 @@ function create(opts) {
|
|
|
260
268
|
var scopeChallenge = scheme + ' error="insufficient_scope"' +
|
|
261
269
|
', scope="' + opts.requiredScopes.join(" ") + '"' +
|
|
262
270
|
(realm ? ', realm="' + realm + '"' : "");
|
|
263
|
-
|
|
264
|
-
error: "insufficient_scope",
|
|
265
|
-
required: opts.requiredScopes.slice(),
|
|
266
|
-
});
|
|
267
|
-
res.writeHead(403, { // HTTP 403 status
|
|
268
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
269
|
-
"Content-Length": Buffer.byteLength(scopeBody),
|
|
270
|
-
"WWW-Authenticate": scopeChallenge,
|
|
271
|
-
});
|
|
272
|
-
res.end(scopeBody);
|
|
271
|
+
_refuse(req, res, 403, scopeChallenge, // HTTP 403 status
|
|
272
|
+
{ error: "insufficient_scope", required: opts.requiredScopes.slice() },
|
|
273
|
+
"insufficient-scope", { required: opts.requiredScopes.slice() }, onDeny, problemMode);
|
|
273
274
|
}
|
|
274
275
|
return;
|
|
275
276
|
}
|
|
@@ -51,6 +51,7 @@ var DEFAULT_BLOCKED_AGENTS = [
|
|
|
51
51
|
var lazyRequire = require("../lazy-require");
|
|
52
52
|
var requestHelpers = require("../request-helpers");
|
|
53
53
|
var validateOpts = require("../validate-opts");
|
|
54
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
54
55
|
var { defineClass } = require("../framework-error");
|
|
55
56
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
56
57
|
|
|
@@ -112,6 +113,8 @@ function _xffIpFor(trustProxy) {
|
|
|
112
113
|
* skipPaths: string[],
|
|
113
114
|
* statusOnBlock: number, // default 403
|
|
114
115
|
* bodyOnBlock: string,
|
|
116
|
+
* onDeny: function(req, res, info): void, // own the block response; info = { status, reason }
|
|
117
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
115
118
|
* trustProxy: boolean|number,
|
|
116
119
|
* }
|
|
117
120
|
*
|
|
@@ -128,7 +131,7 @@ function create(opts) {
|
|
|
128
131
|
opts = opts || {};
|
|
129
132
|
validateOpts(opts, [
|
|
130
133
|
"mode", "onlyForHtml", "allowedAgents", "blockedAgents",
|
|
131
|
-
"skipPaths", "statusOnBlock", "bodyOnBlock", "trustProxy",
|
|
134
|
+
"skipPaths", "statusOnBlock", "bodyOnBlock", "onDeny", "problemDetails", "trustProxy",
|
|
132
135
|
], "middleware.botGuard");
|
|
133
136
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
134
137
|
? opts.trustProxy : false;
|
|
@@ -144,6 +147,8 @@ function create(opts) {
|
|
|
144
147
|
var skipPaths = opts.skipPaths || [];
|
|
145
148
|
var statusOnBlock = opts.statusOnBlock || 403;
|
|
146
149
|
var bodyOnBlock = opts.bodyOnBlock !== undefined ? opts.bodyOnBlock : "Forbidden";
|
|
150
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
151
|
+
var problemMode = opts.problemDetails === true;
|
|
147
152
|
|
|
148
153
|
function _shouldSkip(req) {
|
|
149
154
|
var path = req.pathname || req.url || "/";
|
|
@@ -240,10 +245,17 @@ function create(opts) {
|
|
|
240
245
|
} catch (_e) { /* audit best-effort */ }
|
|
241
246
|
|
|
242
247
|
if (res.writableEnded) return;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
denyResponse(req, res, {
|
|
249
|
+
onDeny: onDeny,
|
|
250
|
+
problem: problemMode,
|
|
251
|
+
status: statusOnBlock,
|
|
252
|
+
info: { status: statusOnBlock, reason: hit },
|
|
253
|
+
problemCode: "bot-blocked",
|
|
254
|
+
problemTitle: "Forbidden",
|
|
255
|
+
problemDetail: "The request was identified as automated traffic and refused.",
|
|
256
|
+
contentType: "text/plain",
|
|
257
|
+
body: bodyOnBlock,
|
|
258
|
+
});
|
|
247
259
|
// Don't call next() — terminate the chain
|
|
248
260
|
};
|
|
249
261
|
}
|
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
|
|