@blamejs/core 0.18.43 → 0.18.45

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.
@@ -466,9 +466,7 @@ function _detectIssues(flow, opts) {
466
466
  // sanitize AFTER resolve -> detect -> throwOnRefusalSeverity. OAuth flows
467
467
  // can't be repaired: any critical/high finding refuses upstream, so the
468
468
  // transform passes the already-validated bundle through unchanged.
469
- function _sanitizeTransform(input) {
470
- return input;
471
- }
469
+ var _sanitizeTransform = gateContract.identitySanitize;
472
470
 
473
471
  /**
474
472
  * @primitive b.guardOauth.gate
package/lib/guard-pdf.js CHANGED
@@ -136,6 +136,11 @@ var PROFILES = Object.freeze({
136
136
 
137
137
  var DEFAULTS = gateContract.strictDefaults(PROFILES);
138
138
 
139
+ // The options that are genuinely CAPS, where zero is not a setting — named once
140
+ // so this guard's hand-bound gate holds a caller to the same rules the
141
+ // generated validate() does. See guard-image for the full reasoning.
142
+ var INT_OPTS = ["maxBytes", "maxPageCount"];
143
+
139
144
  var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 512 });
140
145
 
141
146
  function _hasPdfMagic(buf) {
@@ -436,6 +441,9 @@ function gate(opts) {
436
441
  defaults: DEFAULTS,
437
442
  errorClass: GuardPdfError,
438
443
  errCodePrefix: "pdf",
444
+ // Own resolver, own cap declaration — see guard-image.gate.
445
+ intOpts: INT_OPTS,
446
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
439
447
  });
440
448
  return gateContract.buildGuardGate(
441
449
  opts.name || "guardPdf:" + (opts.profile || "default"),
@@ -2658,9 +2658,7 @@ function _detectNestedExtglob(input, opts, issues) {
2658
2658
  // sanitize AFTER resolve -> detect -> throwOnRefusalSeverity. Regex patterns
2659
2659
  // cannot be safely repaired, so the transform is a pass-through: a non-string
2660
2660
  // or any critical/high finding refuses upstream, clean input returns verbatim.
2661
- function _sanitizeTransform(input) {
2662
- return input;
2663
- }
2661
+ var _sanitizeTransform = gateContract.identitySanitize;
2664
2662
 
2665
2663
  /**
2666
2664
  * @primitive b.guardRegex.gate
@@ -2711,7 +2709,10 @@ function gate(opts) {
2711
2709
  opts.name || "guardRegex:" + (opts.profile || "default"),
2712
2710
  opts,
2713
2711
  async function (ctx) {
2714
- var pattern = ctx && (ctx.identifier || ctx.pattern);
2712
+ // The shared reader, not an `||` chain: the chain collapses a field
2713
+ // present as "" into the next one and ultimately into undefined, so an
2714
+ // empty pattern served while validate("") refused it.
2715
+ var pattern = gateContract.ctxValueFrom(ctx, ["identifier", "pattern"]);
2715
2716
  if (pattern === undefined || pattern === null) {
2716
2717
  return { ok: true, action: "serve" };
2717
2718
  }
@@ -473,8 +473,10 @@ function gate(opts) {
473
473
  _resolveProfile(opts);
474
474
  var name = opts.name || "guardSmtpCommand:" + (opts.profile || opts.posture || "default");
475
475
  return gateContract.buildGuardGate(name, opts, async function (ctx) {
476
- var line = ctx && (ctx.identifier || ctx.commandLine || "");
477
- if (!line) return { ok: true, action: "serve" };
476
+ // The shared reader, not an `||` chain — see guard-regex.gate. An empty
477
+ // command line is a value the validator refuses, not an absent field.
478
+ var line = gateContract.ctxValueFrom(ctx, ["identifier", "commandLine"]);
479
+ if (line === undefined || line === null) return { ok: true, action: "serve" };
478
480
  try {
479
481
  validate(line, opts);
480
482
  return { ok: true, action: "serve" };
package/lib/guard-sql.js CHANGED
@@ -522,6 +522,12 @@ var DEFAULTS = gateContract.strictDefaults(PROFILES, {
522
522
  gdprRedact: false,
523
523
  });
524
524
 
525
+ // The options that are genuinely CAPS, where zero is not a setting — named once
526
+ // so this guard's hand-bound resolver holds a caller to the same rules the
527
+ // generated validate() does. maxRuntimeMs is deliberately absent: zero there
528
+ // means no runtime budget. See guard-image for the full reasoning.
529
+ var INT_OPTS = ["maxBytes"];
530
+
525
531
  // All four postures map to the strict floor — a regulated deployment
526
532
  // gets the tightest raw-SQL gate regardless of which framework it cites.
527
533
  // gdpr additionally redacts the fragment body in the audit trail
@@ -542,6 +548,9 @@ function _resolveOpts(opts) {
542
548
  defaults: DEFAULTS,
543
549
  errorClass: GuardSqlError,
544
550
  errCodePrefix: "sql",
551
+ // Own resolver, own cap declaration — see guard-image.gate.
552
+ intOpts: INT_OPTS,
553
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
545
554
  });
546
555
  }
547
556
 
@@ -1565,6 +1574,7 @@ module.exports = gateContract.defineGuard({
1565
1574
  integrationFixtures: INTEGRATION_FIXTURES,
1566
1575
  validate: validate,
1567
1576
  sanitize: sanitize,
1577
+ intOpts: INT_OPTS,
1568
1578
  gate: gate,
1569
1579
  extra: {
1570
1580
  MIME_TYPES: Object.freeze(["application/sql"]),
package/lib/guard-svg.js CHANGED
@@ -1016,7 +1016,8 @@ var _guard = module.exports = gateContract.defineGuard({
1016
1016
  extensions: [".svg", ".svgz"],
1017
1017
  integrationFixtures: INTEGRATION_FIXTURES,
1018
1018
  detect: _detectIssues,
1019
- intOpts: ["maxBytes", "maxElementCount", "maxUseDepth"],
1019
+ intOpts: ["maxBytes", "maxElementCount", "maxUseDepth",
1020
+ "maxAttrValueBytes", "maxAttrsPerTag"],
1020
1021
  sanitize: sanitize,
1021
1022
  gate: gate,
1022
1023
  extra: {
package/lib/guard-text.js CHANGED
@@ -114,6 +114,12 @@ var DEFAULTS = gateContract.strictDefaults(PROFILES, {
114
114
 
115
115
  var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256 });
116
116
 
117
+ // The options that are genuinely CAPS, where zero is not a setting — named once
118
+ // so this guard's hand-bound resolver holds a caller to the same rules the
119
+ // generated validate() does. maxRuntimeMs is deliberately absent: zero there
120
+ // means no runtime budget. See guard-image for the full reasoning.
121
+ var INT_OPTS = ["maxBytes"];
122
+
117
123
  // ---- Internal helpers ----
118
124
 
119
125
  function _resolveOpts(opts) {
@@ -123,6 +129,9 @@ function _resolveOpts(opts) {
123
129
  defaults: DEFAULTS,
124
130
  errorClass: GuardTextError,
125
131
  errCodePrefix: "text",
132
+ // Own resolver, own cap declaration — see guard-image.gate.
133
+ intOpts: INT_OPTS,
134
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
126
135
  });
127
136
  }
128
137
 
@@ -573,5 +582,6 @@ module.exports = gateContract.defineGuard({
573
582
  integrationFixtures: INTEGRATION_FIXTURES,
574
583
  validate: validate,
575
584
  sanitize: sanitize,
585
+ intOpts: INT_OPTS,
576
586
  gate: gate,
577
587
  });
package/lib/guard-yaml.js CHANGED
@@ -259,6 +259,12 @@ var DEFAULTS = gateContract.strictDefaults(PROFILES, {
259
259
 
260
260
  var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256 });
261
261
 
262
+ // The options that are genuinely CAPS, where zero is not a setting — named once
263
+ // so this guard's hand-bound parse() and gate() hold a caller to the same rules
264
+ // the generated validate() does. See guard-image for the full reasoning.
265
+ var INT_OPTS = ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
266
+ "maxDocuments", "maxNodes", "maxScalarLength"];
267
+
262
268
  // Document separators — a `---` at the start of a line, followed by
263
269
  // whitespace. A line starts at index 0 or after an LF, and those are separate
264
270
  // facts: a document that opens with a blank line has its first separator at
@@ -642,6 +648,9 @@ function parse(input, opts) {
642
648
  defaults: DEFAULTS,
643
649
  errorClass: GuardYamlError,
644
650
  errCodePrefix: "yaml",
651
+ // Own resolver, own cap declaration — see guard-image.gate.
652
+ intOpts: INT_OPTS,
653
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
645
654
  });
646
655
  if (typeof input !== "string") {
647
656
  throw _err("yaml.bad-input", "parse requires string input");
@@ -788,6 +797,9 @@ function gate(opts) {
788
797
  defaults: DEFAULTS,
789
798
  errorClass: GuardYamlError,
790
799
  errCodePrefix: "yaml",
800
+ // Own resolver, own cap declaration — see guard-image.gate.
801
+ intOpts: INT_OPTS,
802
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
791
803
  });
792
804
  return gateContract.buildContentGate({
793
805
  name: opts.name || "guardYaml:" + (opts.profile || "default"),
@@ -809,8 +821,7 @@ module.exports = gateContract.defineGuard({
809
821
  extensions: [".yml", ".yaml"],
810
822
  integrationFixtures: INTEGRATION_FIXTURES,
811
823
  detect: _detectIssues,
812
- intOpts: ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
813
- "maxDocuments", "maxNodes", "maxScalarLength"],
824
+ intOpts: INT_OPTS,
814
825
  gate: gate,
815
826
  dispositionFor: _gateDispositionFor,
816
827
  sanitizeTransform: _sanitizeTransform,
package/lib/mail-auth.js CHANGED
@@ -1184,9 +1184,10 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
1184
1184
  // eight queries however long it is. That is a denial-of-service guard on the
1185
1185
  // RECEIVER, not a nicety — the sender picks the domain.
1186
1186
  // RFC 1035 §2.3.4 — the wire form of a name is at most 253 octets, and a single
1187
- // label at most 63.
1187
+ // label at most 63. The per-label bound is enforced by canonicalDomain, so only
1188
+ // the qname total is measured here: `_dmarc.` is PREPENDED after
1189
+ // canonicalization and can push a name that was inside 253 past it.
1188
1190
  var DMARC_MAX_QNAME_OCTETS = 253;
1189
- var DNS_MAX_LABEL_OCTETS = 63;
1190
1191
  var DMARC_TREE_WALK_MAX_LABELS = 8;
1191
1192
  var DMARC_TREE_WALK_LABEL_FLOOR = 7;
1192
1193
 
@@ -1224,18 +1225,15 @@ function _dmarcAuthorDomainLabels(domain) {
1224
1225
  // both sides end up in one canonical form.
1225
1226
  var d = publicSuffix.canonicalDomain(String(domain));
1226
1227
  if (!d) return null;
1227
- // RFC 1035 §2.3.4 bounds a LABEL at 1..63 octets as well as the whole name at
1228
- // 253, and `canonicalDomain` enforces only the second it is the framework's
1229
- // definition of a domain NAME, and a label cap is a DNS wire rule rather than
1230
- // a naming one. Checking it here keeps the answer the same whichever resolver
1231
- // is wired in: the default one refuses an over-long label as `dns/bad-host`
1232
- // and the evaluation temperrors, while an operator's own `dnsLookup` would
1233
- // answer for it and let a policy apply to a name that cannot exist.
1234
- var labels = d.split(".");
1235
- for (var i = 0; i < labels.length; i += 1) {
1236
- if (labels[i].length === 0 || labels[i].length > DNS_MAX_LABEL_OCTETS) return null;
1237
- }
1238
- return labels;
1228
+ // Both RFC 1035 §2.3.4 bounds 63 octets per label and 253 for the whole
1229
+ // name are enforced by `canonicalDomain` itself, so an over-long or empty
1230
+ // label has already returned "" above. This function used to re-check the
1231
+ // label bound because canonicalDomain enforced only the total; the comment
1232
+ // here argued a label cap was a DNS wire rule rather than a naming one, which
1233
+ // did not survive the observation that the 253 cap is equally a wire rule and
1234
+ // was enforced there anyway. Fixing it at the definition means every caller
1235
+ // gets it, not just this one.
1236
+ return d.split(".");
1239
1237
  }
1240
1238
 
1241
1239
  // The ordered list of names the walk queries, starting with the domain itself.
@@ -26,6 +26,7 @@ var flagContext = require("./flag-context");
26
26
  var assetlinks = require("./assetlinks");
27
27
  var attachUser = require("./attach-user");
28
28
  var bearerAuth = require("./bearer-auth");
29
+ var sharedSecretHeader = require("./shared-secret-header");
29
30
  var bodyParser = require("./body-parser");
30
31
  var clearSiteData = require("./clear-site-data");
31
32
  var botDisclose = require("./bot-disclose");
@@ -84,6 +85,7 @@ module.exports = {
84
85
  rateLimit: rateLimit.create,
85
86
  attachUser: attachUser.create,
86
87
  bearerAuth: bearerAuth.create,
88
+ sharedSecretHeader: sharedSecretHeader.create,
87
89
  requireAal: requireAal.create,
88
90
  requireAuth: requireAuth.create,
89
91
  requireContentType: requireContentType.create,
@@ -149,6 +151,7 @@ module.exports = {
149
151
  rateLimit: rateLimit,
150
152
  attachUser: attachUser,
151
153
  bearerAuth: bearerAuth,
154
+ sharedSecretHeader: sharedSecretHeader,
152
155
  requireAal: requireAal,
153
156
  requireAuth: requireAuth,
154
157
  requireContentType: requireContentType,
@@ -0,0 +1,334 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * shared-secret-header middleware — a named header carrying a shared secret,
6
+ * compared in constant time.
7
+ *
8
+ * This is the third common way a request authenticates itself. The other two
9
+ * already have primitives: `Authorization: Bearer` is `b.middleware.bearerAuth`,
10
+ * and a signed webhook is `b.webhookHmac` / `b.webhook.verify`. A named custom
11
+ * header holding a fixed secret is what internal service-to-service calls,
12
+ * cron triggers and platform bridges use, and hand-rolling it puts four
13
+ * conditions in one expression that have to be in the right order:
14
+ *
15
+ * - the LENGTH check comes first, because `b.crypto.timingSafeEqual` throws
16
+ * on a length mismatch rather than returning false. A compare that runs
17
+ * first turns a wrong-length header into a 500.
18
+ * - an UNCONFIGURED secret refuses. A deployment that forgot the environment
19
+ * variable is otherwise wide open, and looks configured.
20
+ * - the compare does not short-circuit, which is the whole reason
21
+ * timingSafeEqual is there and the thing a `===` "optimisation" removes.
22
+ * - an AVAILABILITY failure is not an authentication failure. A secret
23
+ * resolver that throws still denies — fail closed — but answers 503, so an
24
+ * operator sees a dependency outage rather than a flood of bad
25
+ * credentials, and a caller holding the right secret is not told it is
26
+ * wrong.
27
+ *
28
+ * Every refusal is byte-identical, so the gate cannot be used as an oracle for
29
+ * which condition failed.
30
+ */
31
+
32
+ var C = require("../constants");
33
+ var codepointClass = require("../codepoint-class");
34
+ var bCrypto = require("../crypto");
35
+ var lazyRequire = require("../lazy-require");
36
+ var requestHelpers = require("../request-helpers");
37
+ var safeBuffer = require("../safe-buffer");
38
+ var validateOpts = require("../validate-opts");
39
+ var denyResponse = require("./deny-response").denyResponse;
40
+ var { AuthError } = require("../framework-error");
41
+
42
+ var audit = lazyRequire(function () { return require("../audit"); });
43
+ var observability = lazyRequire(function () { return require("../observability"); });
44
+
45
+ var _err = AuthError.factory;
46
+
47
+ // RFC 9110 §5.6.2 tchar: "!#$%&'*+-.^_`|~", DIGIT, ALPHA. Everything else is a
48
+ // delimiter or a control and cannot appear in a field name. Index of the first
49
+ // offending character, or -1.
50
+ var _TCHAR_SPECIALS = "!#$%&'*+-.^_`|~";
51
+ // How many times `lowerName` appears on the wire. node's IncomingMessage JOINS
52
+ // duplicate custom headers into one comma-separated string rather than exposing
53
+ // an array (only a documented few, like set-cookie, become arrays), so an
54
+ // Array.isArray() check never fires for a custom header — and `rawHeaders` is
55
+ // the only place the duplication is still visible.
56
+ //
57
+ // Returns -1 when the request does not expose rawHeaders at all (a non-node
58
+ // request object, or a framework that rebuilds the request), so the caller can
59
+ // tell "no duplicates" from "cannot tell" instead of reading the second as the
60
+ // first.
61
+ function _rawHeaderCount(req, lowerName) {
62
+ var raw = req && req.rawHeaders;
63
+ if (!Array.isArray(raw)) return -1;
64
+ var n = 0;
65
+ for (var i = 0; i + 1 < raw.length; i += 2) {
66
+ if (String(raw[i]).toLowerCase() === lowerName) n += 1;
67
+ }
68
+ return n;
69
+ }
70
+
71
+ function _firstNonTokenChar(s) {
72
+ for (var i = 0; i < s.length; i += 1) {
73
+ var cc = s.charCodeAt(i);
74
+ if (codepointClass.isAsciiAlnum(cc)) continue;
75
+ if (_TCHAR_SPECIALS.indexOf(s.charAt(i)) !== -1) continue;
76
+ return i;
77
+ }
78
+ return -1;
79
+ }
80
+
81
+ /**
82
+ * @primitive b.middleware.sharedSecretHeader
83
+ * @signature b.middleware.sharedSecretHeader(req, res, next)
84
+ * @since 0.18.44
85
+ * @status stable
86
+ * @related b.middleware.bearerAuth, b.webhookHmac, b.crypto.timingSafeEqual
87
+ *
88
+ * Require a named header to carry a shared secret, compared in constant time.
89
+ * The shape internal service-to-service calls, cron triggers and platform
90
+ * bridges use, alongside `bearerAuth` for `Authorization: Bearer` and
91
+ * `b.webhookHmac` for a signed webhook. Constructed via
92
+ * `b.middleware.sharedSecretHeader(opts)`; the resulting middleware has the
93
+ * `(req, res, next)` shape shown above.
94
+ *
95
+ * `secret` is either the value itself or a function returning it — the
96
+ * resolver form covers a secrets manager or a rotating value, and may be
97
+ * async. Whichever form, an absent or empty secret REFUSES every request. That
98
+ * is the documented default rather than a flag, because the alternative is a
99
+ * deployment that forgot its environment variable, accepts everything, and
100
+ * looks configured.
101
+ *
102
+ * A resolver that THROWS is treated differently from one that returns nothing.
103
+ * Returning nothing means unconfigured, which is an authentication failure —
104
+ * 401. Throwing means the secret could not be fetched, which is not: the
105
+ * caller may well hold the right value and the framework cannot tell. That
106
+ * still denies, but with 503, so the log shows a dependency outage instead of
107
+ * credential failures and a monitor does not page the wrong team.
108
+ *
109
+ * A resolver that returns something which is not a secret — a `Buffer` from a
110
+ * secrets-manager SDK, a number, a parsed JSON envelope — takes the same 503,
111
+ * for the same reason: no usable secret was obtained. Reporting that as 401
112
+ * would tell the operator their callers are wrong and bury a bug in their own
113
+ * resolver under a wall of credential failures.
114
+ *
115
+ * `headerName` must be an RFC 9110 §5.1 token, and a name carrying a space, a
116
+ * colon or any other delimiter is refused at construction. Such a name can
117
+ * never match an incoming header, so the gate would refuse every request
118
+ * forever — and those refusals are indistinguishable from a caller presenting
119
+ * the wrong secret, which is the worst place for a typo to surface.
120
+ *
121
+ * Every refusal — absent header, wrong length, wrong value, repeated header,
122
+ * unconfigured secret — produces the same status and body, so the gate is not
123
+ * an oracle for which of them it was.
124
+ *
125
+ * The repeated-header refusal reads `req.rawHeaders`, because Node joins
126
+ * duplicate custom headers into one comma-separated string rather than an
127
+ * array: without it, a secret equal to the joined value would authenticate a
128
+ * request in which no single header carried it. That reaches as far as this
129
+ * process can see. A reverse proxy that MERGES duplicates before Node receives
130
+ * them leaves one header on the wire and nothing to detect — if the deployment
131
+ * relies on this refusal, configure the proxy to reject duplicate occurrences
132
+ * of the header rather than fold them.
133
+ *
134
+ * @opts
135
+ * headerName: string, // required — an RFC 9110 token, e.g. "x-internal-secret"
136
+ * secret: string | function, // value, or () => value (may be async)
137
+ * audit: boolean, // default true — emit auth.shared_secret.* rows
138
+ * errorMessage: string, // default "Unauthorized"
139
+ * onDeny: function, // custom refusal writer
140
+ * problemDetails: boolean, // RFC 9457 application/problem+json refusals
141
+ *
142
+ * @example
143
+ * router.use("/internal", b.middleware.sharedSecretHeader({
144
+ * headerName: "x-internal-secret",
145
+ * secret: process.env.INTERNAL_SECRET,
146
+ * }));
147
+ * // → a request without the exact secret never reaches the route
148
+ */
149
+ function create(opts) {
150
+ opts = opts || {};
151
+ validateOpts(opts, [
152
+ "headerName", "secret", "audit", "errorMessage", "onDeny", "problemDetails",
153
+ ], "middleware.sharedSecretHeader");
154
+
155
+ var headerName = opts.headerName;
156
+ if (typeof headerName !== "string" || headerName.length === 0) {
157
+ throw _err("auth-shared-secret/bad-header-name",
158
+ "middleware.sharedSecretHeader: opts.headerName must be a non-empty string");
159
+ }
160
+ // A field name is an RFC 9110 §5.1 token. A name carrying a space, a colon or
161
+ // any other delimiter can never match an incoming header, so the gate would
162
+ // refuse every request forever — and those 401s are indistinguishable from a
163
+ // caller presenting the wrong secret. That is a configuration typo, so it
164
+ // belongs at boot where the operator sees it, not at request time where it
165
+ // looks like an attack.
166
+ if (_firstNonTokenChar(headerName) !== -1) {
167
+ throw _err("auth-shared-secret/bad-header-name",
168
+ "middleware.sharedSecretHeader: opts.headerName must be an RFC 9110 token " +
169
+ "(no spaces, colons or delimiters); got " + JSON.stringify(headerName));
170
+ }
171
+ // Node lowercases incoming header names; normalise once at construction so
172
+ // an operator can configure it in any case.
173
+ var headerKey = headerName.toLowerCase();
174
+
175
+ // A secret that is neither a string nor a resolver is a configuration
176
+ // mistake, and belongs at boot rather than as a per-request surprise. An
177
+ // ABSENT secret is deliberately NOT refused here: that is the deployment
178
+ // that forgot its environment variable, and it must fail closed at request
179
+ // time rather than stop the process from starting, so the operator sees
180
+ // 401s and an audit trail rather than a boot loop.
181
+ if (opts.secret !== undefined && opts.secret !== null &&
182
+ typeof opts.secret !== "string" && typeof opts.secret !== "function") {
183
+ throw _err("auth-shared-secret/bad-secret",
184
+ "middleware.sharedSecretHeader: opts.secret must be a string or a function " +
185
+ "returning one; got " + typeof opts.secret);
186
+ }
187
+
188
+ var auditOn = opts.audit !== false;
189
+ var errorMessage = typeof opts.errorMessage === "string" ? opts.errorMessage : "Unauthorized";
190
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
191
+ var problemMode = opts.problemDetails === true;
192
+
193
+ function _emit(req, action, reason) {
194
+ if (!auditOn) return;
195
+ try {
196
+ audit().safeEmit({
197
+ action: action,
198
+ outcome: "denied",
199
+ reason: reason,
200
+ actor: requestHelpers.extractActorContext(req),
201
+ metadata: { header: headerKey, method: req.method, path: requestHelpers.resolveRoute(req) },
202
+ });
203
+ } catch (_e) { /* drop-silent — an audit failure must not decide the request */ }
204
+ }
205
+
206
+ // One refusal writer for every deny path. The status varies (401 vs 503)
207
+ // because those are different facts about the world, but the BODY does not:
208
+ // a caller must not be able to tell an absent header from a wrong value.
209
+ function _refuse(req, res, status, reason) {
210
+ if (res.writableEnded) return;
211
+ denyResponse(req, res, {
212
+ onDeny: onDeny,
213
+ problem: problemMode,
214
+ status: status,
215
+ info: { status: status, reason: reason },
216
+ problemCode: "shared-secret-" + reason,
217
+ problemTitle: status === C.HTTP.STATUS.SERVICE_UNAVAILABLE ? "Service Unavailable" : "Unauthorized",
218
+ problemDetail: errorMessage,
219
+ contentType: "text/plain; charset=utf-8",
220
+ body: errorMessage,
221
+ });
222
+ }
223
+
224
+ return async function sharedSecretHeader(req, res, next) {
225
+ var headers = req.headers || {};
226
+ var presented = headers[headerKey];
227
+
228
+ // A repeated header is refused, whichever way the runtime represents it.
229
+ // node JOINS duplicate custom headers into one comma-separated string
230
+ // rather than an array, so checking Array.isArray alone would never fire
231
+ // on a real request — and a secret that happened to equal the joined value
232
+ // ("alpha, beta" against header lines `alpha` and `beta`) would
233
+ // authenticate a request in which NO single header carried the secret.
234
+ // Verified against node directly: two lines arrive as "alpha, beta" with
235
+ // both occurrences visible only in rawHeaders.
236
+ //
237
+ // Refused before the secret is resolved: the request is malformed whatever
238
+ // the configuration is, and there is no reason to call an operator's
239
+ // resolver for it.
240
+ if (Array.isArray(presented) || _rawHeaderCount(req, headerKey) > 1) {
241
+ _emit(req, "auth.shared_secret.failure", "header-repeated");
242
+ return _refuse(req, res, C.HTTP.STATUS.UNAUTHORIZED, "unauthorized");
243
+ }
244
+
245
+ // Absence is decided BEFORE the secret is resolved. `opts.secret` may be a
246
+ // resolver backed by a secrets manager, and awaiting it first let an
247
+ // unauthenticated client drive that dependency once per request — traffic
248
+ // and latency it never had to earn, and an outage it could amplify — while
249
+ // presenting no credential at all.
250
+ //
251
+ // The verdict is unchanged; only its cost is. During a resolver outage a
252
+ // request with no header now answers 401 rather than 503, which is the more
253
+ // accurate of the two: the caller brought no credential, so the
254
+ // dependency's health is not what decided it.
255
+ if (typeof presented !== "string" || presented.length === 0) {
256
+ _emit(req, "auth.shared_secret.failure", "header-absent");
257
+ return _refuse(req, res, C.HTTP.STATUS.UNAUTHORIZED, "unauthorized");
258
+ }
259
+
260
+ var want;
261
+ if (typeof opts.secret === "function") {
262
+ try {
263
+ want = await opts.secret(req);
264
+ } catch (e) {
265
+ // The secret could not be FETCHED. Not an auth failure — the caller
266
+ // may hold the right value and there is no way to know. Deny, but say
267
+ // so honestly.
268
+ //
269
+ // Contained, including the lazy module load: a broken metrics registry
270
+ // must not throw past this point, because the throw would escape the
271
+ // handler and the router would answer 500. Telemetry does not get to
272
+ // decide the response — least of all on the path whose whole purpose
273
+ // is to fail closed with an honest 503.
274
+ try {
275
+ observability().safeEvent("auth.shared_secret.unavailable", 1, { header: headerKey });
276
+ } catch (_o) { /* drop-silent — see above */ }
277
+ _emit(req, "auth.shared_secret.unavailable", "secret-resolver-failed: " + ((e && e.message) || String(e)));
278
+ return _refuse(req, res, C.HTTP.STATUS.SERVICE_UNAVAILABLE, "unavailable");
279
+ }
280
+ } else {
281
+ want = opts.secret;
282
+ }
283
+
284
+ // A resolver that hands back something that is NOT a secret — a Buffer
285
+ // from a secrets-manager SDK, a number, a parsed JSON envelope — is a bug
286
+ // in the resolver, not a deployment that forgot to configure one. Both
287
+ // deny, but reporting this as 401 tells the operator their CALLERS are
288
+ // wrong and buries the real cause under a wall of credential failures. It
289
+ // is the same fact as a resolver that threw: no usable secret was
290
+ // obtained, so it takes the same 503.
291
+ if (want !== undefined && want !== null && typeof want !== "string") {
292
+ try {
293
+ observability().safeEvent("auth.shared_secret.unavailable", 1, { header: headerKey });
294
+ } catch (_o) { /* drop-silent — telemetry never decides the response */ }
295
+ _emit(req, "auth.shared_secret.unavailable",
296
+ "secret-resolver-returned-" + (Buffer.isBuffer(want) ? "buffer" : typeof want));
297
+ return _refuse(req, res, C.HTTP.STATUS.SERVICE_UNAVAILABLE, "unavailable");
298
+ }
299
+
300
+ // Unconfigured refuses. FIRST, before anything about the request, so a
301
+ // deployment missing its secret cannot be talked into accepting by any
302
+ // header at all.
303
+ if (typeof want !== "string" || want.length === 0) {
304
+ _emit(req, "auth.shared_secret.failure", "secret-not-configured");
305
+ return _refuse(req, res, C.HTTP.STATUS.UNAUTHORIZED, "unauthorized");
306
+ }
307
+
308
+
309
+ var got = safeBuffer.toBuffer(presented, { encoding: "utf8" });
310
+ var expected = safeBuffer.toBuffer(want, { encoding: "utf8" });
311
+ // Length BEFORE the compare: timingSafeEqual throws on a length mismatch
312
+ // rather than returning false, so a compare-first ordering turns a
313
+ // wrong-length header into a 500. The length of a secret is not itself a
314
+ // secret, so answering early here leaks nothing the header size did not.
315
+ if (got.length !== expected.length || !bCrypto.timingSafeEqual(got, expected)) {
316
+ _emit(req, "auth.shared_secret.failure", "mismatch");
317
+ return _refuse(req, res, C.HTTP.STATUS.UNAUTHORIZED, "unauthorized");
318
+ }
319
+
320
+ if (auditOn) {
321
+ try {
322
+ audit().safeEmit({
323
+ action: "auth.shared_secret.success",
324
+ outcome: "success",
325
+ actor: requestHelpers.extractActorContext(req),
326
+ metadata: { header: headerKey, method: req.method, path: requestHelpers.resolveRoute(req) },
327
+ });
328
+ } catch (_e) { /* drop-silent */ }
329
+ }
330
+ return next();
331
+ };
332
+ }
333
+
334
+ module.exports = { create: create };