@blamejs/core 0.18.43 → 0.18.44
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 +106 -0
- package/NOTICE +1 -1
- package/README.md +3 -2
- package/index.js +2 -0
- package/lib/chain-writer.js +103 -4
- package/lib/file-type.js +19 -2
- package/lib/framework-error.js +10 -0
- package/lib/gate-contract.js +255 -12
- package/lib/guard-all.js +1 -0
- package/lib/guard-archive.js +14 -0
- package/lib/guard-auth.js +1 -3
- package/lib/guard-country.js +480 -0
- package/lib/guard-csv.js +5 -0
- package/lib/guard-email.js +7 -7
- package/lib/guard-filename.js +11 -9
- package/lib/guard-graphql.js +1 -3
- package/lib/guard-html.js +6 -7
- package/lib/guard-image.js +15 -1
- package/lib/guard-json.js +3 -7
- package/lib/guard-jwt.js +8 -3
- package/lib/guard-markdown.js +946 -2
- package/lib/guard-oauth.js +1 -3
- package/lib/guard-pdf.js +8 -0
- package/lib/guard-regex.js +5 -4
- package/lib/guard-smtp-command.js +4 -2
- package/lib/guard-sql.js +10 -0
- package/lib/guard-svg.js +2 -1
- package/lib/guard-text.js +10 -0
- package/lib/guard-yaml.js +13 -2
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/shared-secret-header.js +334 -0
- package/lib/time.js +282 -0
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +858 -304
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-oauth.js
CHANGED
|
@@ -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
|
-
|
|
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"),
|
package/lib/guard-regex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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:
|
|
813
|
-
"maxDocuments", "maxNodes", "maxScalarLength"],
|
|
824
|
+
intOpts: INT_OPTS,
|
|
814
825
|
gate: gate,
|
|
815
826
|
dispositionFor: _gateDispositionFor,
|
|
816
827
|
sanitizeTransform: _sanitizeTransform,
|
package/lib/middleware/index.js
CHANGED
|
@@ -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 };
|