@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 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
- if (!res.writableEnded && typeof res.writeHead === "function") {
172
- res.writeHead(451, { // HTTP 451 Unavailable For Legal Reasons
173
- "Content-Type": "application/json; charset=utf-8",
174
- "Cache-Control": "no-store, private",
175
- });
176
- res.end(JSON.stringify({ error: errorMessage, requireAge: requireAge, parentalConsent: false }));
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
- function _writeUnauthorized(res, scheme, message, realm) {
46
- if (res.headersSent) return;
47
- var body = JSON.stringify({ error: message });
48
- var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
49
- res.writeHead(401, { // HTTP 401 status
50
- "Content-Type": "application/json; charset=utf-8",
51
- "Content-Length": Buffer.byteLength(body),
52
- "WWW-Authenticate": challenge,
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
- res.end(body);
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
- var malformedBody = JSON.stringify({ error: errorMessage });
201
- res.writeHead(401, { // HTTP 401 status
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
- var body = JSON.stringify({ error: errorMessage });
225
- res.writeHead(401, { // HTTP 401 status
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
- var scopeBody = JSON.stringify({
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
- if (typeof res.writeHead === "function") {
244
- res.writeHead(statusOnBlock, { "Content-Type": "text/plain" });
245
- res.end(bodyOnBlock);
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
  }
@@ -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
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
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
- if (typeof res.writeHead === "function") {
337
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
338
- res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
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
- if (typeof res.writeHead === "function") {
361
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
362
- res.end("CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)");
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
- if (typeof res.writeHead === "function") {
223
- var body = JSON.stringify({ error: message });
224
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
225
- "Content-Type": "application/json; charset=utf-8",
226
- "Content-Length": Buffer.byteLength(body),
227
- });
228
- res.end(body);
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
- * onExceeded: function(req, res, info): void,
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
- var onExceeded = typeof opts.onExceeded === "function" ? opts.onExceeded : null;
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
- if (onExceeded) {
177
- try { return onExceeded(req, res, info); }
178
- catch (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); }
179
- }
180
- if (!res.writableEnded) {
181
- res.writeHead(429, {
182
- "Content-Type": "application/json; charset=utf-8",
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
- res.end(JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }));
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