@blamejs/core 0.15.67 → 0.15.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/archive-tar.js +7 -0
- package/lib/archive.js +4 -0
- package/lib/csv.js +12 -1
- package/lib/eat.js +2 -1
- package/lib/mail-arc-sign.js +15 -12
- package/lib/mail-arf.js +2 -0
- package/lib/mail-bimi.js +2 -1
- package/lib/mail-bounce.js +21 -6
- package/lib/mail-mdn.js +15 -0
- package/lib/mail-send-deliver.js +17 -7
- package/lib/mail-srs.js +6 -0
- package/lib/middleware/idempotency-key.js +28 -2
- package/lib/mime-parse.js +3 -1
- package/lib/network-dns-resolver.js +53 -18
- package/lib/observability-otlp-exporter.js +2 -2
- package/lib/safe-buffer.js +64 -0
- package/lib/safe-icap.js +5 -0
- package/lib/session.js +47 -32
- package/lib/vc.js +29 -0
- package/lib/vendor/MANIFEST.json +10 -10
- package/lib/vendor/public-suffix-list.dat +2 -4
- package/lib/vendor/public-suffix-list.data.js +745 -745
- package/lib/xml-c14n.js +8 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.68 (2026-07-02) — **A security-hardening release: mail delivery-status / return-receipt / ARC builders reject CR, LF, and NUL in header-line fields (header injection); the XML canonicalizer's nesting cap now actually fires (stack-overflow DoS on deeply-nested SAML/WebDAV input); and the Idempotency-Key replay cache is scoped to the authenticated principal (cross-actor response disclosure) — plus archive/SRS byte hardening.** b.mail composes RFC 3464 delivery-status notifications (b.mail.send.deliver(...).buildDsn, b.mailBounce.dsn.build), RFC 8098 message-disposition notifications (b.mailMdn.build), and RFC 8617 ARC signatures (b.mail.arc.sign) by interpolating operator- and peer-supplied values — recipient addresses, the reporting-MTA name, the remote server's 5xx diagnostic reply, and the ARC authserv-id / domain / selector — into `Name: value` header lines. A value containing a CR or LF let a hostile original sender (whose message is being bounced) or a malicious peer MX (whose 5xx reply is echoed into the DSN) inject an extra header or forge an additional multipart/report part. Structured fields (addresses, MTA names, identifiers) are now rejected outright, and the free-text 5xx diagnostic — which is legitimately multi-line — is folded to a single line. The same class is closed in two adjacent places: tar and zip entry names refuse CR/LF (a bare LF in an over-long tar name flowed into the POSIX pax extended header, where a crafted name could forge a `path=` record that overrides the ustar name and escapes the extraction directory), and SRS envelope-from rewriting refuses control characters in the address it re-emits as a MAIL FROM. A new b.safeBuffer.assertHeaderSafe primitive centralizes the reject-CR/LF/NUL check every header builder now composes. **Added:** *b.safeBuffer.assertHeaderSafe(value, label, ErrorClass, code)* — Throws when a string bound for a CRLF-delimited protocol line contains CR, LF, or a NUL byte, in the caller's own error domain. Structured header fields (addresses, domains, identifiers, MTA names) compose it to reject. · *b.safeBuffer.foldHeaderText(value, replacement?)* — Folds free-text bound for a header line onto one line — replaces every CR and LF with the replacement (default a space) and removes every NUL. Used for values that may legitimately wrap, such as a multi-line SMTP 5xx reply echoed into a DSN diagnostic line, where rejecting would be too strict; unlike a plain CR/LF strip it also removes NUL, which is never valid in a header value and is treated specially by downstream mail parsers. **Changed:** *Refreshed the vendored public-suffix list and the pinned CI action SHAs* — The vendored Mozilla Public Suffix List (which backs registrable-domain boundaries in b.guardDomain and cookie-scope checks) is refreshed to its current upstream revision, and the SHA-pinned GitHub Actions in the release and scanning workflows are bumped to their latest upstream releases. No operator action required. · *Concurrent DNS cache misses are coalesced into a single upstream lookup* — b.network.dns.resolver now single-flights in-flight queries: when several callers miss the cache for the same name at once, one upstream lookup is issued and the others await its result, instead of each firing its own request (a cache stampede that amplified load on the upstream resolver). Each caller still applies its own DNSSEC validate gate against the shared answer. **Security:** *Delivery-status notifications refuse header-injecting fields; the 5xx diagnostic is folded* — b.mail.send.deliver(...).buildDsn and b.mailBounce.dsn.build build an RFC 3464 multipart/report message from the failing recipient, the original sender, the reporting-MTA name, the enhanced status code, and the remote server's 5xx diagnostic reply. The 5xx reply is attacker-influenceable (it comes from the peer MX) and the addresses can come from a message being bounced. A CR or LF in any of these smuggled a new header or forged a report part into a message the operator then relays. Structured fields now reject CR/LF/NUL (the composer throws rather than emit an ambiguous message), and the free-text diagnostic is folded to one line so its text is preserved without introducing a line break. · *Message-disposition notifications refuse header-injecting fields* — b.mailMdn.build places the final recipient, original recipient, original message-id, reporting user-agent, and the From/To/Subject of the return receipt on header lines. Because an MDN is generated in response to an inbound message, these fields can carry attacker-chosen content; a CR or LF injected a header into the receipt. Each structured field now rejects CR/LF/NUL before the message is built. · *ARC signing refuses CR/LF/NUL in the authentication-results identity* — b.mail.arc.sign already refused CR/LF in the authentication-results value, but the ARC authserv-id, signing domain, and selector — interpolated verbatim into the ARC-Authentication-Results, ARC-Seal, and ARC-Message-Signature header block — were only checked for emptiness. A CR or LF in one of them injected a header into the signed block. All three now reject CR/LF/NUL. · *Archive entry names refuse CR/LF, closing a pax path-override escape* — tar and zip entry names already rejected a NUL byte and `..` segments but not CR/LF. In a tar archive, a bare LF in an over-long name flowed into the POSIX pax extended header (`len key=value\n`); because a pax `path=` record overrides the ustar header's name, a crafted name could inject an absolute `path=` record and write outside the extraction directory — an escape the `..` check does not catch. Both the tar and zip name normalizers now reject CR/LF. · *SRS envelope rewriting refuses control characters in the address* — b.mail.srs rewrite / srs1Rewrite / reverse embed the address they are given into the SRS0/SRS1 envelope address that becomes a subsequent SMTP MAIL FROM. They now reject CR/LF/NUL in the input address so a malformed address cannot be smuggled into an envelope command. · *The XML canonicalizer's nesting-depth cap now actually fires (stack-overflow DoS)* — b.xmlC14n.parse — the recursive-descent parser behind SAML and WebDAV canonicalization — carried a documented 200-level nesting cap that was dead: the depth counter was a per-frame-local incremented then decremented around each single recursive call, so it never exceeded 1 at the check. Deeply-nested untrusted XML recursed unbounded and overflowed the stack, crashing the process — a pre-authentication denial of service for any endpoint that canonicalizes attacker-supplied XML. The depth is now threaded through the recursion and enforced at entry, so nesting past the cap is refused before it can exhaust the stack. · *The Idempotency-Key replay cache is scoped to the authenticated principal* — b.middleware.idempotencyKey keyed its replay/response cache slot solely on the client-supplied Idempotency-Key header, with no binding to the authenticated principal. Two principals presenting the same (shared or guessed) key collided: one was served the other's cached response (cross-actor disclosure), or an attacker could pre-seed a key to force a 422 on the real client (cross-actor denial). The slot is now scoped to the authenticated principal, resolved via b.requestHelpers.extractActorContext and overridable with the new opts.scopeFn. Mount the middleware after authentication so the request carries the principal; slots for unauthenticated requests share a single anonymous scope. · *Mail and data parsers reject reserved prototype keys in untrusted input* — b.csv.parse, b.mime-parse (Content-Type parameters), b.eat (Entity Attestation Token claims), b.mailBimi (tiny-PS attributes), b.mailArf (feedback-report fields), and b.safeIcap (ICAP response headers) built plain objects keyed by names taken directly from untrusted input. A key of __proto__, constructor, or prototype would shadow or re-parent the object, letting an attacker taint a downstream type check. Every one of these sites now refuses the reserved keys — CSV throws csv/forbidden-header (an operator DDL surface); the parsers that must stay lenient drop the poisoned key and keep parsing. · *Verifiable-credential JOSE verification binds the algorithm to the key's curve* — b.vc.verify / b.vc.verifyPresentation resolved the public key and then verified with the algorithm named in the attacker-controlled JWS header, with no check that the two agree. A header claiming ES384 (or EdDSA) could be verified against a P-256 key an ECDSA curve/type confusion (CWE-347, RFC 7518 §3.4). The verifier now rejects any alg whose required curve/key-type doesn't match the resolved key, before the signature check. · *Span-event names are redacted on the telemetry wire* — The OTLP span exporter ran span names and exception messages through the telemetry redactor but passed span-EVENT names through verbatim, so a value-shape secret (a connection string, a token) placed in an event name egressed unredacted. Event names now route through the same redactor as span names, on both the JSON and protobuf wire paths. · *Session refresh enforces the idle and absolute timeout floors* — b.session.touch refreshed a session's activity timestamp without re-checking the idle / absolute timeout floors that b.session.verify enforces, so a floor-expired session could be resurrected by a refresh instead of being killed. Both floors are now evaluated on the refresh path (as on verify), and a session past either floor is deleted rather than extended.
|
|
12
|
+
|
|
11
13
|
- v0.15.67 (2026-06-29) — **Path-scoped router middleware can no longer be bypassed by percent-encoding the request path: the router refuses an encoded path separator or null byte and decodes the path once, so a security gate and the resource it guards always agree on the path.** b.router supports path-scoped middleware — router.use('/admin', gate) runs a gate (requireAal, bearerAuth, requireMtls, csrfProtect, …) only for requests under a prefix. The gate matched the request path with its percent-escapes intact, while downstream consumers such as b.staticServe and router.serveStatic percent-decode the path before resolving the file. Because the gate and the consumer disagreed on decoding, an attacker could encode a character in the guarded segment (/%61dmin/secret) or hide a separator (/admin%2fsecret) so the gate's segment match missed while the consumer still reached the protected resource — an authentication, authorization, CSRF, or mTLS bypass for any resource served under a scoped gate. The router now refuses a request whose path contains an encoded path separator (%2f, %5c) or null byte (%00), and decodes the remaining escapes exactly once, so the gate, the route matcher, and every consumer act on a single canonical path. A request that legitimately needs those bytes was always ambiguous and is now rejected with 400 rather than silently routed two different ways. Route parameters captured from the path are now percent-decoded, matching the conventional behavior. **Changed:** *Encoded path separators and null bytes in the request path are refused; route params are decoded* — A request whose path contains %2f, %5c, or %00 is answered with 400 Bad Request — these have no unambiguous routing meaning (a consumer would treat them as a separator or terminator), and a request that needs a literal slash in a value should carry it in a query parameter. Path parameters captured by a route pattern (for example :id) are now percent-decoded before they reach the handler in req.params, matching conventional router behavior; handlers that previously received a still-encoded value will now receive the decoded form. **Security:** *Path-scoped middleware is no longer bypassable via percent-encoded paths* — A gate mounted with router.use(prefix, mw) matched req.pathname with percent-escapes preserved, but b.staticServe and router.serveStatic decode the path before resolving the resource. An attacker could percent-encode a character in the guarded segment (/%61dmin/secret, where %61 is 'a') so the segment compare saw '%61dmin' and skipped the gate, or hide a separator (/admin%2fsecret) so the gate saw one segment while the file resolver saw two — in both cases the protected resource was served without the gate running. The router now rejects a path containing an encoded separator (%2f/%5c) or null byte (%00) with 400, and percent-decodes the path exactly once before any path-scoped decision, so the gate and the resource it protects always resolve the same path. Operators who mounted authentication, authorization, CSRF, or mTLS gates with the scoped form no longer have a silent bypass beneath them.
|
|
12
14
|
|
|
13
15
|
- v0.15.66 (2026-06-29) — **Recursive serializers and parsers refuse pathologically deep input with a typed error instead of overflowing the stack and crashing the process.** Several of the framework's recursive walkers over attacker-reachable input lacked an effective nesting cap, so a deeply nested value could exhaust the V8 call stack and throw an uncaught RangeError — crashing the process (a denial of service). b.canonicalJson is the most exposed: content-credentials verification canonicalises an untrusted manifest before it checks the signature, so a hostile credential could crash a verifier pre-authentication. b.jsonSchema.validate had a depth guard, but its limit was set so high the stack overflowed before the guard could fire, so validating a request body against a recursive schema crashed rather than returning a typed error. b.i18n.messageFormat parsed and rendered nested plural/select cases with no cap. Each walker now throws a typed framework error well before native overflow, and legitimate (even deeply nested) input is unaffected. The release also corrects DNSSEC signature-window comparison to the RFC 1982 serial-number arithmetic the spec requires, and makes the Redis client treat a malformed reply frame as a connection fault rather than letting it crash the host. **Fixed:** *DNSSEC compares RRSIG validity windows with RFC 1982 serial arithmetic* — b.network.dns.dnssec compared an RRSIG's inception and expiration against the current time by magnitude. RFC 4034 §3.1.5 requires the 32-bit timestamps to be interpreted with RFC 1982 serial-number arithmetic, which agrees with a plain comparison only while the values stay below 2^31. A signature whose window straddles the 2^31 (January 2038) or 2^32 (February 2106) boundary was mis-ordered — an in-window signature rejected, or a stale one accepted. The comparison now masks the clock into the same 32-bit serial space and tests the wrapped signed delta. **Security:** *Canonical JSON refuses excessively nested input before it can overflow the stack* — b.canonicalJson.stringify and stringifyJcs walk the value recursively. They detected circular references but had no nesting cap, so a deeply nested (acyclic) object or array overflowed the V8 stack with an uncaught RangeError. This is reachable before authentication: content-credentials verification canonicalises the untrusted manifest to hash it before verifying the signature, so a hostile credential could crash the verifier. Both serializers now throw a typed nesting-depth error at a depth far beyond any legitimate signed document and well short of native overflow. · *JSON Schema validation depth guard now fires before the stack overflows* — b.jsonSchema.validate caps subschema-validation nesting to defend against a recursive schema (for example items pointing back at the root with $ref) applied to a deeply nested instance — both attacker-controlled when validating a request body. The cap was set so high the V8 stack overflowed first, so the guard never ran and a crafted body crashed the validator with an uncaught RangeError instead of the typed reference-depth error. The limit is now well under native overflow; legitimate documents — deeply nested or wide — continue to validate. · *ICU MessageFormat refuses pathologically nested templates* — b.i18n.messageFormat.parse and format recurse once per nested plural/select case, with no depth cap. A template nested thousands of levels deep overflowed the stack. format() and parse() are public and b.i18n.t can render operator-supplied entries, so a hostile template is a denial-of-service vector; it now fails as a typed bad-template error. Real-world nesting (a handful of levels) is unaffected. · *Redis client treats a malformed reply frame as a connection fault, not a crash* — The RESP decoder recurses on nested arrays with no depth cap, and the data handler did not guard the parse, so a malformed or hostilely deep frame from a server threw out of the socket callback and could crash the host. The decoder now caps reply nesting and any parse fault tears the socket down and rejects in-flight commands for a reconnect — the same path as any other lost connection.
|
package/lib/archive-tar.js
CHANGED
|
@@ -70,6 +70,7 @@ var nodeStream = require("node:stream");
|
|
|
70
70
|
var streamPromises = require("node:stream/promises");
|
|
71
71
|
var C = require("./constants");
|
|
72
72
|
var lazyRequire = require("./lazy-require");
|
|
73
|
+
var safeBuffer = require("./safe-buffer");
|
|
73
74
|
var { defineClass } = require("./framework-error");
|
|
74
75
|
|
|
75
76
|
var TarError = defineClass("TarError", { alwaysPermanent: true });
|
|
@@ -240,6 +241,12 @@ function _normalizeName(name) {
|
|
|
240
241
|
if (name.indexOf("\u0000") !== -1) {
|
|
241
242
|
throw new TarError("archive-tar/bad-name", "addFile: name contains null byte");
|
|
242
243
|
}
|
|
244
|
+
// A bare LF flows into the POSIX pax extended header (`len key=value\n`),
|
|
245
|
+
// where a crafted name can forge a `path=` record that OVERRIDES the
|
|
246
|
+
// ustar name and escapes the extraction root; reject CR/LF outright.
|
|
247
|
+
if (safeBuffer.hasCrlf(name)) {
|
|
248
|
+
throw new TarError("archive-tar/bad-name", "addFile: name contains CR/LF");
|
|
249
|
+
}
|
|
243
250
|
var normalized = name.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
244
251
|
var segs = normalized.split("/");
|
|
245
252
|
for (var i = 0; i < segs.length; i += 1) {
|
package/lib/archive.js
CHANGED
|
@@ -59,6 +59,7 @@ var C = require("./constants");
|
|
|
59
59
|
var { defineClass } = require("./framework-error");
|
|
60
60
|
var auditEmit = require("./audit-emit");
|
|
61
61
|
var atomicFile = require("./atomic-file");
|
|
62
|
+
var safeBuffer = require("./safe-buffer");
|
|
62
63
|
|
|
63
64
|
var ArchiveError = defineClass("ArchiveError", { alwaysPermanent: true });
|
|
64
65
|
|
|
@@ -219,6 +220,9 @@ function zip() {
|
|
|
219
220
|
if (name.indexOf("\0") !== -1) {
|
|
220
221
|
throw new ArchiveError("archive/bad-name", "addFile: name contains null byte");
|
|
221
222
|
}
|
|
223
|
+
if (safeBuffer.hasCrlf(name)) {
|
|
224
|
+
throw new ArchiveError("archive/bad-name", "addFile: name contains CR/LF");
|
|
225
|
+
}
|
|
222
226
|
// No path traversal — relative paths only, no leading slash, no ".." segments.
|
|
223
227
|
var normalized = name.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
224
228
|
var segs = normalized.split("/");
|
package/lib/csv.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
var C = require("./constants");
|
|
45
45
|
var numericBounds = require("./numeric-bounds");
|
|
46
|
+
var pick = require("./pick");
|
|
46
47
|
var { defineClass } = require("./framework-error");
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -292,7 +293,17 @@ function parse(input, opts) {
|
|
|
292
293
|
"row " + r + " has " + rec.length + " fields, header has " + header.length);
|
|
293
294
|
}
|
|
294
295
|
var obj = {};
|
|
295
|
-
for (var i = 0; i < header.length; i++)
|
|
296
|
+
for (var i = 0; i < header.length; i++) {
|
|
297
|
+
// A header column named __proto__ / constructor / prototype would be
|
|
298
|
+
// written straight onto the row object, shadowing (constructor) or
|
|
299
|
+
// dropping/re-parenting (__proto__) the inherited slot — an attacker
|
|
300
|
+
// then controls a downstream `row.constructor` / type check. Refuse.
|
|
301
|
+
if (pick.isPoisonedKey(header[i])) {
|
|
302
|
+
throw new CsvError("csv/forbidden-header",
|
|
303
|
+
"header column '" + header[i] + "' is a reserved prototype key");
|
|
304
|
+
}
|
|
305
|
+
obj[header[i]] = rec[i];
|
|
306
|
+
}
|
|
296
307
|
out.push(obj);
|
|
297
308
|
}
|
|
298
309
|
return out;
|
package/lib/eat.js
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
var cwt = require("./cwt");
|
|
50
50
|
var bCrypto = require("./crypto");
|
|
51
|
+
var pick = require("./pick");
|
|
51
52
|
var validateOpts = require("./validate-opts");
|
|
52
53
|
var { defineClass } = require("./framework-error");
|
|
53
54
|
|
|
@@ -229,7 +230,7 @@ async function verify(eat, opts) {
|
|
|
229
230
|
if (name === "dbgstat" && typeof v === "number" && Object.prototype.hasOwnProperty.call(DBGSTAT_BY_VALUE, v)) {
|
|
230
231
|
v = DBGSTAT_BY_VALUE[v];
|
|
231
232
|
}
|
|
232
|
-
claims[name] = v;
|
|
233
|
+
if (!pick.isPoisonedKey(name)) claims[name] = v;
|
|
233
234
|
});
|
|
234
235
|
|
|
235
236
|
return { claims: claims, raw: raw, alg: out.alg, protectedHeaders: out.protectedHeaders };
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -188,12 +188,21 @@ function sign(opts) {
|
|
|
188
188
|
throw new MailAuthError("arc-sign/bad-instance",
|
|
189
189
|
"sign: instance must be an integer in [1, 50] — got " + JSON.stringify(opts.instance));
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"
|
|
191
|
+
// authservId / domain / selector / authResults are required non-empty
|
|
192
|
+
// strings AND are interpolated verbatim into the ARC header block
|
|
193
|
+
// (ARC-Authentication-Results / ARC-Seal / ARC-Message-Signature), so they
|
|
194
|
+
// can never carry a CR / LF / NUL — that would smuggle a header.
|
|
195
|
+
var arcHeaderFields = [
|
|
196
|
+
["authservId", opts.authservId, "arc-sign/bad-authserv"],
|
|
197
|
+
["domain", opts.domain, "arc-sign/bad-domain"],
|
|
198
|
+
["selector", opts.selector, "arc-sign/bad-selector"],
|
|
199
|
+
["authResults", opts.authResults, "arc-sign/bad-auth-results"],
|
|
200
|
+
];
|
|
201
|
+
for (var fi = 0; fi < arcHeaderFields.length; fi += 1) {
|
|
202
|
+
var af = arcHeaderFields[fi];
|
|
203
|
+
validateOpts.requireNonEmptyString(af[1], "sign: " + af[0], MailAuthError, af[2]);
|
|
204
|
+
safeBuffer.assertHeaderSafe(af[1], af[0], MailAuthError, af[2]);
|
|
205
|
+
}
|
|
197
206
|
if (!opts.privateKey || (typeof opts.privateKey !== "string" &&
|
|
198
207
|
typeof opts.privateKey !== "object")) {
|
|
199
208
|
throw new MailAuthError("arc-sign/missing-private-key",
|
|
@@ -216,12 +225,6 @@ function sign(opts) {
|
|
|
216
225
|
throw new MailAuthError("arc-sign/cv-rule",
|
|
217
226
|
"sign: i>=2 disallows cv=none — must be cv=pass or cv=fail (per RFC 8617 §5.1.1)");
|
|
218
227
|
}
|
|
219
|
-
validateOpts.requireNonEmptyString(opts.authResults, "sign: authResults",
|
|
220
|
-
MailAuthError, "arc-sign/bad-auth-results");
|
|
221
|
-
if (safeBuffer.hasCrlf(opts.authResults)) {
|
|
222
|
-
throw new MailAuthError("arc-sign/bad-auth-results",
|
|
223
|
-
"sign: authResults contains CR/LF (header injection refused)");
|
|
224
|
-
}
|
|
225
228
|
var headersToSign = opts.headersToSign || DEFAULT_HEADERS;
|
|
226
229
|
if (!Array.isArray(headersToSign) || headersToSign.length === 0) {
|
|
227
230
|
throw new MailAuthError("arc-sign/bad-headers",
|
package/lib/mail-arf.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
var lazyRequire = require("./lazy-require");
|
|
41
41
|
var mimeParse = require("./mime-parse");
|
|
42
|
+
var pick = require("./pick");
|
|
42
43
|
var C = require("./constants");
|
|
43
44
|
var safeBuffer = require("./safe-buffer");
|
|
44
45
|
var { MailArfError } = require("./framework-error");
|
|
@@ -237,6 +238,7 @@ function parse(rawMessage, opts) {
|
|
|
237
238
|
var f = reportFields[fi];
|
|
238
239
|
if (!f || !f.name) continue;
|
|
239
240
|
var lcName = f.name.toLowerCase();
|
|
241
|
+
if (pick.isPoisonedKey(lcName)) continue;
|
|
240
242
|
fieldMap[lcName] = f.value;
|
|
241
243
|
}
|
|
242
244
|
|
package/lib/mail-bimi.js
CHANGED
|
@@ -52,6 +52,7 @@ var nodeCrypto = require("node:crypto");
|
|
|
52
52
|
|
|
53
53
|
var asn1 = require("./asn1-der");
|
|
54
54
|
var C = require("./constants");
|
|
55
|
+
var pick = require("./pick");
|
|
55
56
|
var httpClient = require("./http-client");
|
|
56
57
|
var lazyRequire = require("./lazy-require");
|
|
57
58
|
var networkDnsResolver = lazyRequire(function () { return require("./network-dns-resolver"); });
|
|
@@ -572,7 +573,7 @@ function _parseTinyPsAttrs(src) {
|
|
|
572
573
|
while ((m = re.exec(src)) !== null) {
|
|
573
574
|
var name = m[1];
|
|
574
575
|
var value = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : (m[5] || ""));
|
|
575
|
-
attrs[name] = value;
|
|
576
|
+
if (!pick.isPoisonedKey(name)) attrs[name] = value;
|
|
576
577
|
}
|
|
577
578
|
return attrs;
|
|
578
579
|
}
|
package/lib/mail-bounce.js
CHANGED
|
@@ -812,7 +812,11 @@ function _parseDsn(rawMessage) {
|
|
|
812
812
|
|
|
813
813
|
function _foldFieldValue(name, value) {
|
|
814
814
|
// RFC 5322 §2.2.3 — long lines fold at WSP. Keep it simple: emit
|
|
815
|
-
// `Name: value` and let downstream MTAs handle further folding.
|
|
815
|
+
// `Name: value` and let downstream MTAs handle further folding. This
|
|
816
|
+
// choke point emits a single `Name: value` line, so reject CR / LF /
|
|
817
|
+
// NUL in the value — an embedded terminator would smuggle a new field
|
|
818
|
+
// or forge a report part.
|
|
819
|
+
safeBuffer.assertHeaderSafe(value, name, MailBounceError, "bounce/bad-dsn-field");
|
|
816
820
|
return name + ": " + value + "\r\n";
|
|
817
821
|
}
|
|
818
822
|
|
|
@@ -836,10 +840,21 @@ function _buildDsn(opts) {
|
|
|
836
840
|
String(opts.status) + "'");
|
|
837
841
|
}
|
|
838
842
|
|
|
843
|
+
// CRLF/NUL header-injection guard. Recipients and MTA names are
|
|
844
|
+
// structured fields that can never legitimately carry CR / LF / NUL, so
|
|
845
|
+
// reject — a DSN built from a hostile recipient or peer must fail closed
|
|
846
|
+
// rather than smuggle a header or forge a report part. Diagnostic-Code
|
|
847
|
+
// echoes the remote server's SMTP reply (free text, legitimately
|
|
848
|
+
// multi-line), so fold it to a single line instead of rejecting.
|
|
849
|
+
safeBuffer.assertHeaderSafe(opts.finalRecipient, "finalRecipient", MailBounceError, "bounce/bad-dsn-field");
|
|
850
|
+
if (opts.originalRecipient != null) {
|
|
851
|
+
safeBuffer.assertHeaderSafe(opts.originalRecipient, "originalRecipient", MailBounceError, "bounce/bad-dsn-field");
|
|
852
|
+
}
|
|
839
853
|
var reportingMta = opts.reportingMta || "dns; localhost";
|
|
840
854
|
var arrivalDate = opts.arrivalDate || new Date().toUTCString();
|
|
841
855
|
var originalMessage = opts.originalMessage || null;
|
|
842
|
-
var diagnosticCode = opts.diagnosticCode
|
|
856
|
+
var diagnosticCode = opts.diagnosticCode != null
|
|
857
|
+
? safeBuffer.foldHeaderText(String(opts.diagnosticCode), " ") : null;
|
|
843
858
|
var remoteMta = opts.remoteMta || null;
|
|
844
859
|
var humanText = opts.humanText || (
|
|
845
860
|
"This is the mail system at " + reportingMta + ".\r\n\r\n" +
|
|
@@ -854,10 +869,10 @@ function _buildDsn(opts) {
|
|
|
854
869
|
var lines = [];
|
|
855
870
|
lines.push("MIME-Version: 1.0");
|
|
856
871
|
lines.push('Content-Type: multipart/report; report-type=delivery-status; boundary="' + boundary + '"');
|
|
857
|
-
if (opts.from) lines.push("From: " + opts.from);
|
|
858
|
-
if (opts.to) lines.push("To: " + opts.to);
|
|
859
|
-
if (opts.subject) lines.push("Subject: " + opts.subject);
|
|
860
|
-
if (opts.messageId) lines.push("Message-ID: " + opts.messageId);
|
|
872
|
+
if (opts.from) lines.push("From: " + safeBuffer.assertHeaderSafe(opts.from, "from", MailBounceError, "bounce/bad-dsn-field"));
|
|
873
|
+
if (opts.to) lines.push("To: " + safeBuffer.assertHeaderSafe(opts.to, "to", MailBounceError, "bounce/bad-dsn-field"));
|
|
874
|
+
if (opts.subject) lines.push("Subject: " + safeBuffer.assertHeaderSafe(opts.subject, "subject", MailBounceError, "bounce/bad-dsn-field"));
|
|
875
|
+
if (opts.messageId) lines.push("Message-ID: " + safeBuffer.assertHeaderSafe(opts.messageId, "messageId", MailBounceError, "bounce/bad-dsn-field"));
|
|
861
876
|
lines.push("");
|
|
862
877
|
|
|
863
878
|
// Part 1 - human-readable description.
|
package/lib/mail-mdn.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
var bCrypto = require("./crypto");
|
|
32
32
|
var lazyRequire = require("./lazy-require");
|
|
33
33
|
var mimeParse = require("./mime-parse");
|
|
34
|
+
var safeBuffer = require("./safe-buffer");
|
|
34
35
|
var structuredFields = require("./structured-fields");
|
|
35
36
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
36
37
|
var C = require("./constants");
|
|
@@ -210,6 +211,20 @@ function build(opts) {
|
|
|
210
211
|
"and opts.requireUserConfirmation is not explicitly false (RFC 3798 §2.1)");
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// CRLF/NUL header-injection guard. Every structured MDN field
|
|
215
|
+
// (recipients, message-id, envelope headers, reporting UA) is a
|
|
216
|
+
// single-line header value that can never legitimately carry CR / LF /
|
|
217
|
+
// NUL — an inbound message must not be able to smuggle headers into the
|
|
218
|
+
// return receipt. Guard before addressType() so a hostile address fails
|
|
219
|
+
// closed with the mdn error, not an address-parse error.
|
|
220
|
+
safeBuffer.assertHeaderSafe(opts.finalRecipient, "finalRecipient", MailMdnError, "mdn/bad-header-field");
|
|
221
|
+
safeBuffer.assertHeaderSafe(opts.originalMessageId, "originalMessageId", MailMdnError, "mdn/bad-header-field");
|
|
222
|
+
if (opts.originalRecipient != null) safeBuffer.assertHeaderSafe(opts.originalRecipient, "originalRecipient", MailMdnError, "mdn/bad-header-field");
|
|
223
|
+
if (opts.reportingUserAgent != null) safeBuffer.assertHeaderSafe(opts.reportingUserAgent, "reportingUserAgent", MailMdnError, "mdn/bad-header-field");
|
|
224
|
+
if (opts.from != null) safeBuffer.assertHeaderSafe(opts.from, "from", MailMdnError, "mdn/bad-header-field");
|
|
225
|
+
if (opts.to != null) safeBuffer.assertHeaderSafe(opts.to, "to", MailMdnError, "mdn/bad-header-field");
|
|
226
|
+
if (opts.subject != null) safeBuffer.assertHeaderSafe(opts.subject, "subject", MailMdnError, "mdn/bad-header-field");
|
|
227
|
+
|
|
213
228
|
var boundary = _generateBoundary();
|
|
214
229
|
var recipType = mimeParse.addressType(opts.finalRecipient);
|
|
215
230
|
var origRecipType = opts.originalRecipient ? mimeParse.addressType(opts.originalRecipient) : recipType;
|
package/lib/mail-send-deliver.js
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
var nodeDns = require("node:dns").promises;
|
|
64
64
|
var bCrypto = require("./crypto");
|
|
65
|
+
var safeBuffer = require("./safe-buffer");
|
|
65
66
|
var validateOpts = require("./validate-opts");
|
|
66
67
|
var lazyRequire = require("./lazy-require");
|
|
67
68
|
var { defineClass } = require("./framework-error");
|
|
@@ -115,10 +116,19 @@ function _classifySmtpOutcome(err, response) {
|
|
|
115
116
|
// headers per RFC 3462. Returns a raw RFC 5322 message ready to hand
|
|
116
117
|
// to whatever transport the operator uses for DSN delivery.
|
|
117
118
|
function _buildDsnMessage(opts) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// CRLF/NUL header-injection guard. Structured fields (addresses, the
|
|
120
|
+
// reporting-MTA name, the enhanced status code) can never legitimately
|
|
121
|
+
// carry CR / LF / NUL, so reject — a bounce built from a hostile
|
|
122
|
+
// original sender, or from a malicious peer MX, must fail closed rather
|
|
123
|
+
// than smuggle DSN headers or forge report parts. The 5xx `reason` is
|
|
124
|
+
// echoed from the peer's SMTP reply and is legitimately multi-line, so
|
|
125
|
+
// fold it to a single line instead of rejecting.
|
|
126
|
+
var from = safeBuffer.assertHeaderSafe(opts.dsnFrom, "dsnFrom", DeliverError, "deliver/bad-dsn-field");
|
|
127
|
+
var to = safeBuffer.assertHeaderSafe(opts.originalFrom, "originalFrom", DeliverError, "deliver/bad-dsn-field");
|
|
128
|
+
var failedRecipient = safeBuffer.assertHeaderSafe(opts.recipient, "recipient", DeliverError, "deliver/bad-dsn-field");
|
|
129
|
+
var reportingMta = safeBuffer.assertHeaderSafe(opts.reportingMta, "reportingMta", DeliverError, "deliver/bad-dsn-field");
|
|
130
|
+
var statusCode = safeBuffer.assertHeaderSafe(opts.statusCode, "statusCode", DeliverError, "deliver/bad-dsn-field");
|
|
131
|
+
var reason = safeBuffer.foldHeaderText(opts.reason || "permanent failure", " ");
|
|
122
132
|
var origHeaders = opts.originalHeaders || "";
|
|
123
133
|
var boundary = "dsn-" + bCrypto.generateToken(12);
|
|
124
134
|
var nowIso = new Date().toUTCString();
|
|
@@ -134,7 +144,7 @@ function _buildDsnMessage(opts) {
|
|
|
134
144
|
"--" + boundary + "\r\n" +
|
|
135
145
|
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
136
146
|
"\r\n" +
|
|
137
|
-
"This is the mail delivery system at " + (
|
|
147
|
+
"This is the mail delivery system at " + (reportingMta || from) + ".\r\n" +
|
|
138
148
|
"\r\n" +
|
|
139
149
|
"Your message to " + failedRecipient + " could not be delivered:\r\n" +
|
|
140
150
|
"\r\n" +
|
|
@@ -147,12 +157,12 @@ function _buildDsnMessage(opts) {
|
|
|
147
157
|
// (it falls back to the bounce-from's domain); it drives no auth decision or
|
|
148
158
|
// delivery routing, so the leftmost-@ segment is acceptable here.
|
|
149
159
|
// allow:leftmost-domain-informational
|
|
150
|
-
"Reporting-MTA: dns; " + (
|
|
160
|
+
"Reporting-MTA: dns; " + (reportingMta || from.split("@")[1] || "") + "\r\n" +
|
|
151
161
|
"Arrival-Date: " + nowIso + "\r\n" +
|
|
152
162
|
"\r\n" +
|
|
153
163
|
"Final-Recipient: rfc822; " + failedRecipient + "\r\n" +
|
|
154
164
|
"Action: failed\r\n" +
|
|
155
|
-
"Status: " + (
|
|
165
|
+
"Status: " + (statusCode || "5.0.0") + "\r\n" +
|
|
156
166
|
"Diagnostic-Code: smtp; " + reason + "\r\n" +
|
|
157
167
|
"\r\n" +
|
|
158
168
|
"--" + boundary + "\r\n" +
|
package/lib/mail-srs.js
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
var nodeCrypto = require("node:crypto");
|
|
55
55
|
var bCrypto = require("./crypto");
|
|
56
|
+
var safeBuffer = require("./safe-buffer");
|
|
56
57
|
var validateOpts = require("./validate-opts");
|
|
57
58
|
var { defineClass } = require("./framework-error");
|
|
58
59
|
|
|
@@ -193,6 +194,9 @@ function create(opts) {
|
|
|
193
194
|
function rewrite(originalAddress, nowMs) {
|
|
194
195
|
validateOpts.requireNonEmptyString(
|
|
195
196
|
originalAddress, "srs.rewrite.address", SrsError, "srs/bad-address");
|
|
197
|
+
// The local-part is embedded verbatim into the SRS0 envelope address —
|
|
198
|
+
// refuse CR / LF / NUL so it can't be smuggled into a later MAIL FROM.
|
|
199
|
+
safeBuffer.assertHeaderSafe(originalAddress, "srs.rewrite.address", SrsError, "srs/bad-address");
|
|
196
200
|
var at = originalAddress.lastIndexOf("@");
|
|
197
201
|
if (at <= 0 || at === originalAddress.length - 1) {
|
|
198
202
|
throw new SrsError("srs/bad-address",
|
|
@@ -221,6 +225,7 @@ function create(opts) {
|
|
|
221
225
|
function srs1Rewrite(srsAddress) {
|
|
222
226
|
validateOpts.requireNonEmptyString(
|
|
223
227
|
srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
|
|
228
|
+
safeBuffer.assertHeaderSafe(srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
|
|
224
229
|
var at = srsAddress.lastIndexOf("@");
|
|
225
230
|
if (at <= 0 || at === srsAddress.length - 1) {
|
|
226
231
|
throw new SrsError("srs/bad-address",
|
|
@@ -264,6 +269,7 @@ function create(opts) {
|
|
|
264
269
|
function reverse(srsAddress, nowMs) {
|
|
265
270
|
validateOpts.requireNonEmptyString(
|
|
266
271
|
srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
|
|
272
|
+
safeBuffer.assertHeaderSafe(srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
|
|
267
273
|
var at = srsAddress.lastIndexOf("@");
|
|
268
274
|
if (at <= 0 || at === srsAddress.length - 1) {
|
|
269
275
|
throw new SrsError("srs/bad-address",
|
|
@@ -45,6 +45,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
45
45
|
var numericBounds = require("../numeric-bounds");
|
|
46
46
|
var validateOpts = require("../validate-opts");
|
|
47
47
|
var safeBuffer = require("../safe-buffer");
|
|
48
|
+
var requestHelpers = require("../request-helpers");
|
|
48
49
|
var safeJson = require("../safe-json");
|
|
49
50
|
var safeSql = require("../safe-sql");
|
|
50
51
|
var sql = require("../sql");
|
|
@@ -537,6 +538,18 @@ function _validateStore(store, where) {
|
|
|
537
538
|
where + ": store", IdempotencyError, "idempotency/bad-store", true);
|
|
538
539
|
}
|
|
539
540
|
|
|
541
|
+
// Bind the idempotency slot to the authenticated principal. Without it the
|
|
542
|
+
// slot is keyed on the client Idempotency-Key alone, so principal B is
|
|
543
|
+
// served principal A's cached response (cross-actor disclosure), or A can
|
|
544
|
+
// pre-seed a key to force a 422 on B (cross-actor denial), via a shared or
|
|
545
|
+
// guessed key. Mirrors b.agent.idempotency's (method, actorId, key) binding.
|
|
546
|
+
// Falls back to "anon" only when no principal is resolvable — mount this
|
|
547
|
+
// middleware AFTER authentication so the request carries the principal.
|
|
548
|
+
function _defaultScope(req) {
|
|
549
|
+
var actor = requestHelpers.extractActorContext(req);
|
|
550
|
+
return actor && typeof actor.userId === "string" && actor.userId ? actor.userId : "anon";
|
|
551
|
+
}
|
|
552
|
+
|
|
540
553
|
function _fingerprintRequest(req, bodyBytes, store) {
|
|
541
554
|
// Fingerprint preimage = method + path + body. Per the draft §4.3,
|
|
542
555
|
// a key+body mismatch is a client-side mistake; our preimage covers
|
|
@@ -676,6 +689,13 @@ function create(opts) {
|
|
|
676
689
|
opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
|
|
677
690
|
IdempotencyError, "idempotency/bad-body-fingerprint"
|
|
678
691
|
) || null;
|
|
692
|
+
// Principal scope for the idempotency slot (see _defaultScope). Binds the
|
|
693
|
+
// cache key to the authenticated actor so one principal cannot read or
|
|
694
|
+
// 422-poison another's slot via a shared / guessed Idempotency-Key.
|
|
695
|
+
// Operators with a non-standard principal shape override via opts.scopeFn.
|
|
696
|
+
var scopeFn = validateOpts.optionalFunction(
|
|
697
|
+
opts.scopeFn, "idempotencyKey.scopeFn",
|
|
698
|
+
IdempotencyError, "idempotency/bad-scope-fn") || _defaultScope;
|
|
679
699
|
// Default "deny" refuses body-bearing requests that
|
|
680
700
|
// arrive with neither req._rawBody / req.body NOR an operator-
|
|
681
701
|
// supplied bodyFingerprint hook. The silent-degrade-to-method+path
|
|
@@ -730,6 +750,12 @@ function create(opts) {
|
|
|
730
750
|
return problemDetails().respond(res, bad);
|
|
731
751
|
}
|
|
732
752
|
|
|
753
|
+
// Scope the store slot to the authenticated principal (length-prefixed
|
|
754
|
+
// so the scope/key boundary is unambiguous) — a shared or guessed
|
|
755
|
+
// Idempotency-Key cannot cross the actor boundary.
|
|
756
|
+
var scope = String(scopeFn(req) || "anon");
|
|
757
|
+
var scopedKey = scope.length + ":" + scope + ":" + key;
|
|
758
|
+
|
|
733
759
|
var bodyBytes;
|
|
734
760
|
if (bodyFingerprintFn) {
|
|
735
761
|
// Operator-supplied hook — called after body-parser so req.body
|
|
@@ -801,7 +827,7 @@ function create(opts) {
|
|
|
801
827
|
var fingerprint = _fingerprintRequest(req, bodyBytes, opts.store);
|
|
802
828
|
|
|
803
829
|
var cached = null;
|
|
804
|
-
try { cached = opts.store.get(
|
|
830
|
+
try { cached = opts.store.get(scopedKey); }
|
|
805
831
|
catch (_storeErr) {
|
|
806
832
|
// Store-read failure — emit audit + treat as miss. Idempotency is
|
|
807
833
|
// a best-effort optimization; the handler runs anyway.
|
|
@@ -887,7 +913,7 @@ function create(opts) {
|
|
|
887
913
|
} catch (_e) { /* ignore */ }
|
|
888
914
|
var combined = collector.result();
|
|
889
915
|
try {
|
|
890
|
-
opts.store.set(
|
|
916
|
+
opts.store.set(scopedKey, {
|
|
891
917
|
fingerprint: fingerprint,
|
|
892
918
|
statusCode: status,
|
|
893
919
|
headers: headerMap,
|
package/lib/mime-parse.js
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* mdn: 1 MiB) so this module's hot-path doesn't add its own cap.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
var pick = require("./pick");
|
|
41
|
+
|
|
40
42
|
function classifyHeaderBlock(text) {
|
|
41
43
|
// RFC 5322 §2.2 — every line of a header section is either a header field
|
|
42
44
|
// (`name: value`), a folding continuation (leading WSP), or the empty line
|
|
@@ -153,7 +155,7 @@ function parseContentType(value) {
|
|
|
153
155
|
pval = rest.slice(j, endTok).trim();
|
|
154
156
|
i = endTok;
|
|
155
157
|
}
|
|
156
|
-
params[pname] = pval;
|
|
158
|
+
if (!pick.isPoisonedKey(pname)) params[pname] = pval;
|
|
157
159
|
}
|
|
158
160
|
return { type: typePart, params: params };
|
|
159
161
|
}
|
|
@@ -232,6 +232,7 @@ function create(opts) {
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
var cache = new Map(); // key → { response, parsed, ttl, expiresAt, staleUntil }
|
|
235
|
+
var inflight = new Map(); // key → Promise (single-flight: coalesce concurrent misses)
|
|
235
236
|
|
|
236
237
|
// CWE-400/770. LRU eviction on insert when the cache is at
|
|
237
238
|
// capacity. v8 Map preserves insertion order; oldest key is the
|
|
@@ -291,11 +292,53 @@ function create(opts) {
|
|
|
291
292
|
return _result(hit.parsed, hit.ttl, true, false, hit.validated);
|
|
292
293
|
}
|
|
293
294
|
|
|
294
|
-
// Cache miss / expired
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// own
|
|
295
|
+
// Cache miss / expired. Single-flight: coalesce concurrent misses for the
|
|
296
|
+
// same key so N simultaneous callers trigger ONE upstream lookup instead
|
|
297
|
+
// of a thundering herd (cache stampede). The winner fills the cache; the
|
|
298
|
+
// others await it and re-derive their own verdict from the shared entry
|
|
299
|
+
// (applying their own validate gate — a non-validating fill mustn't hand a
|
|
300
|
+
// validating caller an AD=0 pass).
|
|
301
|
+
if (inflight.has(key)) {
|
|
302
|
+
try { await inflight.get(key).promise; } catch (_e) { /* winner failed — re-check cache, else fetch ourselves below */ }
|
|
303
|
+
var shared = cache.get(key);
|
|
304
|
+
if (shared && shared.expiresAt > Date.now()) {
|
|
305
|
+
if (validate && !shared.validated) {
|
|
306
|
+
throw new ResolverError("resolver/validate-failed",
|
|
307
|
+
"query: validate: true but cached response was AD=0 for " + name + "/" + qtype);
|
|
308
|
+
}
|
|
309
|
+
_touch(key, shared);
|
|
310
|
+
return _result(shared.parsed, shared.ttl, true, false, shared.validated);
|
|
311
|
+
}
|
|
312
|
+
// No fresh shared entry (winner errored / served stale) — fetch ourselves.
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Wrap the fill promise in a plain entry object so the finally-guard below
|
|
316
|
+
// compares object identity, not promise identity: if this fill errors and a
|
|
317
|
+
// follower re-registers its own fresh entry for the same key before this
|
|
318
|
+
// finally runs, the winner deletes only its OWN slot, never the follower's.
|
|
319
|
+
var entry = { promise: _fetchFill(name, qtype, key, hit, now) };
|
|
320
|
+
inflight.set(key, entry);
|
|
321
|
+
var filled;
|
|
322
|
+
try { filled = await entry.promise; }
|
|
323
|
+
finally { if (inflight.get(key) === entry) inflight.delete(key); }
|
|
324
|
+
if (filled.stale) return filled.result;
|
|
325
|
+
if (validate && !filled.validated) {
|
|
326
|
+
throw new ResolverError("resolver/validate-failed",
|
|
327
|
+
"query: validate: true but upstream returned AD=0 for " + name + "/" + qtype);
|
|
328
|
+
}
|
|
329
|
+
return _result(filled.parsed, filled.ttl, false, false, filled.validated);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The single upstream fetch + parse + cache fill, shared by all coalesced
|
|
333
|
+
// callers. Returns { stale:false, parsed, ttl, validated } on a fresh fill,
|
|
334
|
+
// { stale:true, result } when serving stale on an upstream/parse failure, or
|
|
335
|
+
// throws on an unrecoverable error. Does NOT apply the per-caller validate
|
|
336
|
+
// gate — that stays with the caller so callers with different validate flags
|
|
337
|
+
// share one lookup.
|
|
338
|
+
async function _fetchFill(name, qtype, key, hit, now) {
|
|
339
|
+
// withTimeout rejects the await so a non-responsive / stalling endpoint
|
|
340
|
+
// can't hold the request pending forever; the default _wireLookup also
|
|
341
|
+
// tears the socket down on its own req.setTimeout so the fd is released.
|
|
299
342
|
var wireResponse;
|
|
300
343
|
try {
|
|
301
344
|
wireResponse = await safeAsync.withTimeout(
|
|
@@ -307,7 +350,7 @@ function create(opts) {
|
|
|
307
350
|
// Upstream failure — serve stale if we have it within window.
|
|
308
351
|
if (hit && serveStale > 0 && hit.staleUntil > now) {
|
|
309
352
|
_safeEmit("served_stale", { name: name, qtype: qtype, reason: "upstream-failure" });
|
|
310
|
-
return _result(hit.parsed, hit.ttl, true, true, hit.validated);
|
|
353
|
+
return { stale: true, result: _result(hit.parsed, hit.ttl, true, true, hit.validated) };
|
|
311
354
|
}
|
|
312
355
|
throw new ResolverError("resolver/upstream-failed",
|
|
313
356
|
"query: upstream lookup failed for " + name + "/" + qtype + ": " + (e && e.message || String(e)));
|
|
@@ -320,7 +363,7 @@ function create(opts) {
|
|
|
320
363
|
// Malformed upstream response — serve stale if within window.
|
|
321
364
|
if (hit && serveStale > 0 && hit.staleUntil > now) {
|
|
322
365
|
_safeEmit("served_stale", { name: name, qtype: qtype, reason: "parse-failed" });
|
|
323
|
-
return _result(hit.parsed, hit.ttl, true, true, hit.validated);
|
|
366
|
+
return { stale: true, result: _result(hit.parsed, hit.ttl, true, true, hit.validated) };
|
|
324
367
|
}
|
|
325
368
|
throw e;
|
|
326
369
|
}
|
|
@@ -332,18 +375,10 @@ function create(opts) {
|
|
|
332
375
|
}
|
|
333
376
|
|
|
334
377
|
// AD bit (RFC 4035 §3.2.3) — set by upstream after chain validation.
|
|
335
|
-
// Bit 5 of byte 3 of header; parsed.flags is the full 16-bit flags
|
|
336
|
-
// field at offset 2..3. AD is bit 5 within byte 3 = bit 5 of the
|
|
337
|
-
// low byte of the 16-bit flags value.
|
|
338
378
|
var ad = (parsed.flags & 0x0020) !== 0; // RFC 4035 §3.2.3 AD-bit mask within DNS header flags
|
|
339
|
-
if (validate && !ad) {
|
|
340
|
-
throw new ResolverError("resolver/validate-failed",
|
|
341
|
-
"query: validate: true but upstream returned AD=0 for " + name + "/" + qtype);
|
|
342
|
-
}
|
|
343
379
|
|
|
344
|
-
// Compute effective TTL — min across answer RRs (RFC 2181 §5.2
|
|
345
|
-
//
|
|
346
|
-
// to [minTtlMs, maxTtlMs] to bound any single RR's TTL from
|
|
380
|
+
// Compute effective TTL — min across answer RRs (RFC 2181 §5.2), then
|
|
381
|
+
// clamped to [minTtlMs, maxTtlMs] to bound any single RR's TTL from
|
|
347
382
|
// pinning a poisoned entry past operator policy.
|
|
348
383
|
var rrTtl = _minTtl(parsed.answer);
|
|
349
384
|
var ttlMs = Math.max(minTtlMs, Math.min(maxTtlMs, rrTtl * C.TIME.seconds(1)));
|
|
@@ -358,7 +393,7 @@ function create(opts) {
|
|
|
358
393
|
validated: ad,
|
|
359
394
|
});
|
|
360
395
|
_safeEmit("cached", { name: name, qtype: qtype, ttlMs: ttlMs, adBit: ad });
|
|
361
|
-
return
|
|
396
|
+
return { stale: false, parsed: parsed, ttl: ttlMs, validated: ad };
|
|
362
397
|
}
|
|
363
398
|
|
|
364
399
|
function _result(parsed, ttlMs, fromCache, stale, validated) {
|
|
@@ -161,7 +161,7 @@ function _spanToOtlp(span) {
|
|
|
161
161
|
droppedAttributesCount: span.droppedAttributesCount || 0,
|
|
162
162
|
events: (span.events || []).map(function (e) {
|
|
163
163
|
return {
|
|
164
|
-
name: e.name,
|
|
164
|
+
name: _redactWireString("otel.event.name", e.name),
|
|
165
165
|
timeUnixNano: e.timeUnixNano,
|
|
166
166
|
attributes: _attrToOtlp(e.attributes),
|
|
167
167
|
droppedAttributesCount: 0,
|
|
@@ -357,7 +357,7 @@ function _spanToProto(span) {
|
|
|
357
357
|
var eventsRepeated = pb.repeatedMessage(11, span.events || [], function (e) {
|
|
358
358
|
return Buffer.concat([
|
|
359
359
|
pb.fixed64(1, e.timeUnixNano || 0),
|
|
360
|
-
pb.string(2, e.name || ""),
|
|
360
|
+
pb.string(2, _redactWireString("otel.event.name", e.name || "")),
|
|
361
361
|
pb.repeatedMessage(3, _attrsToProto(e.attributes), _keyValueToProto),
|
|
362
362
|
pb.uint32(4, 0),
|
|
363
363
|
]);
|