@blamejs/core 0.17.9 → 0.17.11
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 +4 -0
- package/lib/asn1-der.js +12 -0
- package/lib/auth/oauth.js +60 -13
- package/lib/compliance-ai-act-prohibited.js +8 -4
- package/lib/crypto-xwing.js +22 -4
- package/lib/gdpr-ropa.js +8 -1
- package/lib/mail-server-mx.js +92 -6
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.17.x
|
|
10
10
|
|
|
11
|
+
- v0.17.11 (2026-07-17) — **The DER decoder rejects a non-minimal length encoding, and the OAuth client-attestation verifier pins the JWT type so a JWT minted for another purpose can't be replayed into the attestation slot.** The shared ASN.1 DER decoder accepted a non-minimal long-form length encoding (a long form used for a value that fits the short form, or a length carrying a leading-zero octet), a BER/DER parser-differential an attacker could use to smuggle an alternate encoding of a certificate, CMS structure, or OCSP response past the strict decoder. And the OAuth client-attestation verifier did not pin the JOSE type header of the attestation and proof-of-possession JWTs, so a JWT minted for another purpose but signed by the same key could be replayed into the attestation slot. **Security:** *DER decoder rejects a non-minimal length encoding* — b.asn1Der is the shared DER reader every certificate, CMS, and OCSP parse in the framework routes through. X.690 §10.1 requires the minimum number of length octets: the long form may be used only for lengths of 128 or more, and must carry no leading-zero octet. The decoder accepted both non-minimal forms -- a long-form length for a value that fits the short form, and a long-form length with a leading zero -- which is a BER/DER parser-differential an attacker can use to encode the same structure two ways and slip an alternate encoding past a strict verifier that canonicalizes differently. The decoder already refused every other BER-ism it encounters (indefinite-length, non-minimal OID sub-identifiers, non-minimal high-tag-number tags); it now also refuses a non-minimal length, closing the remaining differential. · *OAuth client-attestation verifier pins the JWT type* — b.auth.oauth's attestation-based client authentication (draft-ietf-oauth-attestation-based-client-auth) verifies two JWTs -- the client-attestation JWT and its proof-of-possession JWT -- each of which carries a REQUIRED, distinct JOSE type header (oauth-client-attestation+jwt and oauth-client-attestation-pop+jwt). The verifier checked the signature and claims but not the type header, so a JWT minted for a different purpose but signed by the same key -- a private_key_jwt client assertion, or the other proof-of-possession JWT -- could be replayed into the attestation or PoP slot (the cross-JWT confused-deputy class RFC 8725 §3.11 explicit typing defends against). The verifier now pins the expected type for each slot, and the builders emit the same literals so a produced and a checked type can never drift; the framework's DPoP and back-channel-logout JWT verifiers already pin their type, and the attestation verifier now matches.
|
|
12
|
+
|
|
13
|
+
- v0.17.10 (2026-07-17) — **A CIDR-scoped SMTP relay allowlist is now actually enforced instead of relaying for the whole internet, EU AI-Act social scoring is flagged prohibited for any actor rather than only public authorities, and the X-Wing hybrid KEM stays uniform implicit-rejection on a low-order X25519 point.** The mail server's relay authorization discarded its arguments and returned allowed for every connecting peer whenever an operator configured any relayAllowedFor entry, so a per-CIDR relay allowlist -- intended to scope relay to registered sources -- turned the server into an open relay for the entire internet. The EU AI-Act prohibited-practice classifier gated social scoring on the deployer being a public authority, a limitation that was in the 2021 Commission proposal but dropped from the adopted Regulation (EU) 2024/1689, so a private-actor social-scoring system was under-classified as not-prohibited. The GDPR record-of-processing updater validated a legal-basis change with a truthiness guard, so a falsy-but-invalid value skipped the enum check and could corrupt a required Article 30 field. And the X-Wing hybrid post-quantum KEM threw a raw derivation error on a low-order X25519 point instead of the uniform implicit-rejection its contract and the draft specification require, exposing a decapsulation-oracle distinguisher and a crash for any consumer following the documented no-try/catch advice. **Security:** *SMTP relay allowlist enforces its per-entry CIDR instead of relaying for everyone* — b.mail.server.mx accepts a relayAllowedFor allowlist of { cidr, scope } entries, documented as scoping relay to registered source ranges over the default MX-only, no-relay posture. The relay-authorization check discarded both the peer address and recipient and simply returned allowed whenever the allowlist was non-empty, so an operator restricting relay to, for example, 10.0.0.0/8 actually granted relay to every peer on the internet -- an open relay, directly contradicting the module's advertised open-relay defense. The check now admits relay only when the connecting peer's source address falls inside one of the allowlisted ranges (using the same range arithmetic the HTTP network-allowlist fence uses); a peer outside every range is refused. Each relayAllowedFor entry's cidr is now validated at startup -- a mask is required so a bare IP cannot silently disable an entry, private and reserved ranges are allowed since relay allowlists legitimately name them, and a malformed entry throws at boot rather than mis-scoping relay. · *AI-Act classifier flags social scoring by any actor as prohibited* — b.compliance.aiAct's prohibited-practice classifier implements EU AI-Act Article 5. Its social-scoring gate (Art. 5(1)(c)) required both the purpose to be social scoring and the deployer to be a public authority before flagging the practice as prohibited. The "by public authorities or on their behalf" limitation existed in the 2021 Commission proposal but was removed from the adopted Regulation (EU) 2024/1689, which prohibits social scoring by any actor. A private-actor social-scoring system was therefore under-classified as not-prohibited. The classifier now flags social scoring on the purpose alone, matching the adopted text; the other Article 5 gates (predictive policing based solely on profiling, untargeted facial-recognition scraping, emotion inference in the workplace/education outside medical/safety uses) correctly retain the conjuncts that are part of their statutory definitions rather than over-broad limitations. · *GDPR record-of-processing rejects a falsy-but-invalid legal basis on update* — b.gdpr.ropa records the Article 30 register of processing activities, whose legalBasis field is constrained to the six Article 6(1) bases. register validates the basis against the allowlist, but update validated a legal-basis change with a truthiness guard, so a falsy-but-invalid value (an empty string, and similar) short-circuited past the enum check and was written into the record -- silently corrupting a required Article 30 field. The updater now validates whenever the patch touches legalBasis, keyed on the field being present rather than truthy, so an invalid value is rejected with the same rigor register applies and a genuine change to a valid basis still succeeds. · *X-Wing hybrid KEM stays uniform implicit-rejection on a low-order X25519 point* — b.crypto.xwing implements the X-Wing hybrid post-quantum KEM (ML-KEM-768 combined with X25519), whose contract and the draft-connolly-cfrg-xwing-kem specification require decapsulation to be uniform implicit-rejection: a tampered ciphertext yields a different secret, never an error, so a consumer must not branch on success. But a hostile low-order X25519 point in the ciphertext (or in the recipient's X25519 public half) drove the X25519 derivation to an all-zero shared secret, which the underlying library aborts with a raw derivation error rather than returning the value RFC 7748 X25519 (without the optional abort) yields. That exposed a decapsulation-oracle distinguisher (a low-order point throws while every other ciphertext returns a secret) and a crash for any caller following the documented advice not to wrap decapsulation in a try/catch. The shared X25519 step now translates that abort back into the all-zero shared secret, restoring uniform implicit-rejection; the combiner still binds the X25519 ciphertext and public key and the ML-KEM-768 leg still protects the result, so the hybrid's secure-if-either-holds guarantee is unchanged.
|
|
14
|
+
|
|
11
15
|
- v0.17.9 (2026-07-17) — **A prototype-member regime name no longer voids a breach-notification clock into reporting a missed deadline as met, a planted plaintext file can no longer stand in for the sealed keyed-hash MAC key, and content-safety sanitization recomputes the integrity descriptor over the bytes actually delivered.** The incident-report deadline clock resolved its per-regime statutory table by a plain-object lookup with no own-property check, so a regime name equal to an inherited object member (valueOf, toString, constructor) resolved to a prototype function instead of the default deadlines -- every due-by became NaN, and a notification filed long past a statutory wall was recorded on-time, reporting a missed regulatory deadline as met. The retention posture-floor lookup had the same defect. The vault's keyed-hash MAC key reader trusted the vault's documented pass-through of a non-sealed value, so a plaintext file substituted on disk was accepted as the secret MAC key without the vault passphrase. And the static file server and the upload finalizer both replaced the served/stored payload when a content-safety gate sanitized it but kept advertising the ORIGINAL bytes' ETag/SRI (static) and sha3/size descriptor (upload), so a client validating the integrity of the delivered body -- or an operator storing the reported hash as the dedup/integrity key -- got a digest that did not match what was actually delivered. **Security:** *Breach-notification and retention lookups reject a prototype-member name* — b.incident.report's deadline clock resolves the statutory deadline set for an incident's regime (GDPR 72h, NIS2 24h, DORA 4h initial, HIPAA 60 days, ...) by indexing a per-regime table with the operator-supplied regime string. The lookup used a truthiness check with no own-property guard, so a regime equal to an inherited object member -- valueOf, toString, constructor, hasOwnProperty -- resolved to the prototype function instead of falling back to the default deadlines. Every computed due-by then became NaN, and because a late comparison against NaN is never true, a notification filed long past the real statutory wall was recorded on-time (not late) and the clock's late count stayed zero -- a genuinely missed regulatory deadline reported to a regulator as met. b.retention's compliance-posture floor lookup (hipaa, pci-dss, gdpr, soc2) had the identical defect, resolving a prototype-member posture to an inherited function as the retention floor. Both now resolve the name through an own-property check, so a prototype-member name falls back to the default deadlines (incident-report) or is rejected as an unknown posture (retention); real regimes and postures are unaffected. · *Vault refuses a non-sealed file as the keyed-hash MAC key* — b.vault derives a keyed-hash MAC key (used by field-level encryption's keyed derived-hash mode to keep an attacker with disk access from correlating low-entropy plaintexts) and seals it at rest. The reader loaded the sealed file and called the vault's unseal, which by its documented idempotent-read contract returns any value lacking the vault seal prefix verbatim -- so a plaintext 32-byte value planted on disk passed straight through and was accepted as the MAC key. In wrapped-vault mode this let an attacker with disk-write access but no vault passphrase (and therefore unable to forge a genuine seal) inject a known MAC key, downgrading the keyed derived-hash to an attacker-known key. The reader now requires the on-disk value to actually be sealed (carry the vault prefix) before unsealing, refusing a substituted plaintext or otherwise-unsealed file -- the same load-bearing prefix check the database key loader and the key-rotation pipeline already apply to their sealed key material. · *Content-safety sanitization recomputes the served body's integrity descriptor* — When a content-safety gate returns a sanitize action it replaces the payload with the cleaned bytes. b.staticServe still emitted the strong ETag and subresource-integrity (SRI) header derived from the original file on disk, and b.fileUpload's finalizer still handed onFinalize (and recorded in the audit) the sha3 and size of the original assembled bytes -- so the integrity descriptor advertised for the delivered/stored bytes did not match them. A browser performing SRI verification of the sanitized static response would fail the check, a strong-validator or If-None-Match cache would be keyed to a representation the client never receives, and an operator storing the upload's reported sha3 as the integrity or dedup key of the stored (sanitized) file would record a hash that never matches it. Both now recompute the ETag/SRI (static) and the sha3/size descriptor (upload) over the bytes actually delivered whenever a gate sanitized the payload; an unsanitized response keeps the on-disk/reassembly digest, which already describes the delivered bytes.
|
|
12
16
|
|
|
13
17
|
- v0.17.8 (2026-07-17) — **The AI-Act transparency HTML emitters now escape every interpolated value, closing a reflected-XSS through a banner's language attribute and a script-context breakout through the JSON-LD disclosure.** b.compliance.aiAct.transparency.htmlBanner rendered its lang value -- a free-form string that is typically a request locale, Accept-Language, or query parameter when the banner is server-rendered -- into a double-quoted HTML attribute by raw concatenation, so a lang containing a double quote broke out of the attribute and injected active content (reflected XSS). Only the element text was escaped before; the attribute values were not. And b.compliance.aiAct.transparency.jsonLdDisclosure embedded the watermark manifest (operator-supplied strings such as the model id and deployer name) inside a <script type="application/ld+json"> element with raw JSON.stringify, which does not escape </script>; the HTML parser ends a script element at the first </script> regardless of its type, so a manifest value carrying that sequence terminated the block early and injected markup. Both emitters now escape at the sink -- every attribute value through the HTML-entity escaper and the JSON-LD payload through the script-safe serializer -- so no interpolated value can break out of its context. **Security:** *AI-Act transparency banner escapes its attribute values* — b.compliance.aiAct.transparency.htmlBanner builds a status banner whose lang attribute carries a free-form language value -- in a server-rendered banner that value commonly comes from the request (a locale, an Accept-Language header, or a query parameter). The banner escaped its visible text but concatenated the lang, article, and kind values into their double-quoted HTML attributes raw, so a lang value containing a double quote closed the attribute early and let the remainder inject an element or event handler into the page (reflected cross-site scripting, CWE-79). Every attribute value is now passed through the HTML-entity escaper before interpolation, matching the escaping the banner text already had, so a hostile value is rendered as inert text inside the attribute rather than breaking out of it; the escape-at-the-sink handling also covers the article and kind values even though their range is currently constrained. · *AI-Act JSON-LD disclosure cannot break out of its script element* — b.compliance.aiAct.transparency.jsonLdDisclosure emits the watermark manifest as JSON-LD inside a <script type="application/ld+json"> element, serializing operator-supplied manifest strings (the model id, deployer name, prompt hash, and similar). It used raw JSON.stringify, which does not escape the sequence </script>; because an HTML parser terminates a script element at the first </script> regardless of the element's type, a manifest value containing </script> (or an HTML comment opener) ended the disclosure block early and injected arbitrary markup that the browser then parsed and could execute. The disclosure now serializes the manifest with the framework's script-safe serializer, which escapes <, >, & and the U+2028/U+2029 separators to their \uXXXX form so the parsed JSON is unchanged but no substring can break out of the script context.
|
package/lib/asn1-der.js
CHANGED
|
@@ -132,6 +132,18 @@ function readNode(buf, offset) {
|
|
|
132
132
|
for (var i = 0; i < lenOctets; i += 1) {
|
|
133
133
|
length = (length * 256) + buf[offset + headerLen + i]; // base-256 length bytes
|
|
134
134
|
}
|
|
135
|
+
// X.690 §10.1: DER requires the MINIMUM number of length octets. Long
|
|
136
|
+
// form is only permitted for lengths >= 128, and must carry no leading-
|
|
137
|
+
// zero octet — reject both non-minimal forms so an attacker-supplied
|
|
138
|
+
// certificate / CMS / OCSP response cannot smuggle a BER/DER parser-
|
|
139
|
+
// differential encoding through the shared walker. The decoder already
|
|
140
|
+
// refuses every other BER-ism (indefinite length, non-minimal OID
|
|
141
|
+
// subidentifiers, non-minimal high-tag-number tags); enforcing minimal
|
|
142
|
+
// lengths closes the remaining gap.
|
|
143
|
+
if (length < 128 || buf[offset + headerLen] === 0) { // X.690 §10.1 short-form boundary / no leading-zero octet
|
|
144
|
+
throw new Asn1Error("asn1/length-non-minimal",
|
|
145
|
+
"long-form length is not DER-minimal (length=" + length + ")");
|
|
146
|
+
}
|
|
135
147
|
headerLen += lenOctets;
|
|
136
148
|
}
|
|
137
149
|
|
package/lib/auth/oauth.js
CHANGED
|
@@ -512,6 +512,13 @@ var ATTESTATION_ALGS = Object.freeze([
|
|
|
512
512
|
"EdDSA",
|
|
513
513
|
]);
|
|
514
514
|
|
|
515
|
+
// draft-ietf-oauth-attestation-based-client-auth §4.1 / §5.1 — the REQUIRED,
|
|
516
|
+
// distinct `typ` header of each JWT. One literal per role, used by BOTH the
|
|
517
|
+
// builders (the value we emit) and the verifier (the value we pin) so the
|
|
518
|
+
// produced typ and the checked typ can never drift apart.
|
|
519
|
+
var ATTESTATION_JWT_TYP = "oauth-client-attestation+jwt";
|
|
520
|
+
var ATTESTATION_POP_JWT_TYP = "oauth-client-attestation-pop+jwt";
|
|
521
|
+
|
|
515
522
|
// Cap on an attestation / PoP JWT. HTTP-header-borne JWTs are small;
|
|
516
523
|
// 16 KiB refuses a pathological header without touching real tokens.
|
|
517
524
|
var MAX_ATTESTATION_JWT_BYTES = C.BYTES.kib(16);
|
|
@@ -582,7 +589,10 @@ function _signAttestationJws(header, payload, privateKey, alg) {
|
|
|
582
589
|
// Verify a compact JWS against an already-imported public KeyObject. The
|
|
583
590
|
// alg is read from the header but MUST equal expectedAlg AND match the
|
|
584
591
|
// key's kty (via the shared cross-check) — no alg-confusion window.
|
|
585
|
-
|
|
592
|
+
// `expectedTyp` (when supplied) pins the JOSE `typ` header so a JWT minted
|
|
593
|
+
// for another purpose but signed by the same key can't be replayed into the
|
|
594
|
+
// attestation / PoP slot.
|
|
595
|
+
function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
586
596
|
if (typeof jws !== "string" || jws.length === 0) {
|
|
587
597
|
throw new OAuthError("auth-oauth/attestation-malformed", label + ": JWT must be a non-empty string");
|
|
588
598
|
}
|
|
@@ -618,6 +628,18 @@ function _verifyAttestationJws(jws, publicKeyJwk, label) {
|
|
|
618
628
|
throw new OAuthError("auth-oauth/attestation-crit-not-supported",
|
|
619
629
|
label + ": JWS 'crit' header is not supported (RFC 7515 §4.1.11)");
|
|
620
630
|
}
|
|
631
|
+
// Explicit typing (RFC 8725 §3.11 / draft-ietf-oauth-attestation-based-
|
|
632
|
+
// client-auth §6): the attestation and PoP JWTs each carry a REQUIRED,
|
|
633
|
+
// distinct `typ`. Pinning it stops a JWT minted for a different purpose
|
|
634
|
+
// but signed by the same key (a private_key_jwt client assertion, another
|
|
635
|
+
// proof-of-possession JWT) from being replayed into the attestation / PoP
|
|
636
|
+
// slot — the cross-JWT confused-deputy class. The framework's other JWS
|
|
637
|
+
// verifiers already pin typ (dpop+jwt, logout+jwt); this one now matches.
|
|
638
|
+
if (typeof expectedTyp === "string" && header.typ !== expectedTyp) {
|
|
639
|
+
throw new OAuthError("auth-oauth/attestation-wrong-typ",
|
|
640
|
+
label + ": header.typ must be '" + expectedTyp + "' (RFC 8725 §3.11 " +
|
|
641
|
+
"explicit typing); got " + JSON.stringify(header.typ));
|
|
642
|
+
}
|
|
621
643
|
// CVE-2026-22817 — cross-check alg against the key's kty before verify.
|
|
622
644
|
jwtExternal._assertAlgKtyMatch(header.alg, publicKeyJwk);
|
|
623
645
|
var keyObject = _jwkToKey(publicKeyJwk);
|
|
@@ -744,7 +766,7 @@ function buildClientAttestation(aopts) {
|
|
|
744
766
|
validateOpts.assignOwnEnumerable(payload, aopts.extraClaims, Object.keys(payload));
|
|
745
767
|
}
|
|
746
768
|
return _signAttestationJws(
|
|
747
|
-
{ typ:
|
|
769
|
+
{ typ: ATTESTATION_JWT_TYP, alg: alg }, payload, key, alg);
|
|
748
770
|
}
|
|
749
771
|
|
|
750
772
|
/**
|
|
@@ -819,7 +841,7 @@ function buildClientAttestationPop(popts) {
|
|
|
819
841
|
payload.challenge = popts.challenge; // draft §5.2 — server nonce
|
|
820
842
|
}
|
|
821
843
|
return _signAttestationJws(
|
|
822
|
-
{ typ:
|
|
844
|
+
{ typ: ATTESTATION_POP_JWT_TYP, alg: alg }, payload, key, alg);
|
|
823
845
|
}
|
|
824
846
|
|
|
825
847
|
/**
|
|
@@ -878,7 +900,8 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
|
|
|
878
900
|
"auth-oauth/attestation-no-expected-aud");
|
|
879
901
|
|
|
880
902
|
// 1. Attestation signature against the TRUSTED attester key.
|
|
881
|
-
var att = _verifyAttestationJws(attestationJwt, vopts.attesterJwk, "client-attestation"
|
|
903
|
+
var att = _verifyAttestationJws(attestationJwt, vopts.attesterJwk, "client-attestation",
|
|
904
|
+
ATTESTATION_JWT_TYP);
|
|
882
905
|
var ap = att.payload || {};
|
|
883
906
|
if (typeof ap.sub !== "string" || ap.sub.length === 0) {
|
|
884
907
|
throw new OAuthError("auth-oauth/attestation-no-sub",
|
|
@@ -912,7 +935,8 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
|
|
|
912
935
|
}
|
|
913
936
|
|
|
914
937
|
// 2. PoP signature against the attestation's cnf key (NOT the attester).
|
|
915
|
-
var pop = _verifyAttestationJws(popJwt, ap.cnf.jwk, "client-attestation-pop"
|
|
938
|
+
var pop = _verifyAttestationJws(popJwt, ap.cnf.jwk, "client-attestation-pop",
|
|
939
|
+
ATTESTATION_POP_JWT_TYP);
|
|
916
940
|
var pp = pop.payload || {};
|
|
917
941
|
// aud MUST be THIS AS issuer (constant-time, exact). Attacker-replayed
|
|
918
942
|
// PoP minted for a different AS is refused (draft §8 step 7).
|
|
@@ -1756,20 +1780,43 @@ function create(opts) {
|
|
|
1756
1780
|
|
|
1757
1781
|
async function _normalizeTokens(raw, vopts) {
|
|
1758
1782
|
vopts = vopts || {};
|
|
1783
|
+
// RFC 6749 §3.3 — scope is space-separated, ONLY U+0020. `\s+` previously
|
|
1784
|
+
// matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning
|
|
1785
|
+
// `scope: "admin<NEL>read"` would surface as `["admin", "read"]` and the
|
|
1786
|
+
// operator's scope allowlist saw two distinct scopes. Spec-strict split on
|
|
1787
|
+
// single-space keeps a non-token separator inside one token.
|
|
1788
|
+
//
|
|
1789
|
+
// §5.1 also fixes the PRESENT-vs-ABSENT distinction that a bare truthiness
|
|
1790
|
+
// test lost: an ABSENT scope ("OPTIONAL, if identical to the requested
|
|
1791
|
+
// scope") means the client got what it asked for → mirror the request; a
|
|
1792
|
+
// PRESENT scope is authoritative, INCLUDING the empty string, which grants
|
|
1793
|
+
// ZERO scopes. Coercing "" to the requested set (its falsy value slipped
|
|
1794
|
+
// the old `raw.scope ? …` test into the absent branch) would report a
|
|
1795
|
+
// downscoped-to-nothing grant as the full requested set — a scope-based
|
|
1796
|
+
// authorization guard downstream then treats denied scopes as granted. A
|
|
1797
|
+
// malformed non-string scope is treated as zero (fail closed), never as
|
|
1798
|
+
// the requested set.
|
|
1799
|
+
var grantedScope;
|
|
1800
|
+
if (typeof raw.scope === "string") {
|
|
1801
|
+
grantedScope = raw.scope.split(" ").filter(function (s) { return s.length > 0; });
|
|
1802
|
+
} else if (raw.scope === undefined) {
|
|
1803
|
+
// ONLY a truly ABSENT property (undefined) mirrors the request (RFC 6749
|
|
1804
|
+
// §3.3 "OPTIONAL, if identical to the requested scope"). A PRESENT but
|
|
1805
|
+
// malformed value — `null`, a number, an object — is NOT an omitted
|
|
1806
|
+
// scope; treating `{ "scope": null }` as absent would copy the full
|
|
1807
|
+
// requested set and report a grant the AS never made. Fall through to
|
|
1808
|
+
// zero (fail closed).
|
|
1809
|
+
grantedScope = scope.slice();
|
|
1810
|
+
} else {
|
|
1811
|
+
grantedScope = [];
|
|
1812
|
+
}
|
|
1759
1813
|
var tokens = {
|
|
1760
1814
|
accessToken: raw.access_token,
|
|
1761
1815
|
tokenType: raw.token_type || "Bearer",
|
|
1762
1816
|
expiresIn: raw.expires_in || null,
|
|
1763
1817
|
refreshToken: raw.refresh_token || null,
|
|
1764
1818
|
idToken: raw.id_token || null,
|
|
1765
|
-
|
|
1766
|
-
// `\s+` previously matched U+0085 NEL, U+00A0 NBSP, etc., so a
|
|
1767
|
-
// hostile AS returning `scope: "admin<NEL>read"` would
|
|
1768
|
-
// surface as `["admin", "read"]` and the operator's scope
|
|
1769
|
-
// allowlist saw two distinct scopes. Spec-strict split on
|
|
1770
|
-
// single-space + reject scope tokens that contain non-token
|
|
1771
|
-
// chars.
|
|
1772
|
-
scope: raw.scope ? raw.scope.split(" ").filter(function (s) { return s.length > 0; }) : scope.slice(),
|
|
1819
|
+
scope: grantedScope,
|
|
1773
1820
|
raw: raw,
|
|
1774
1821
|
};
|
|
1775
1822
|
if (tokens.idToken && isOidc) {
|
|
@@ -51,7 +51,7 @@ var PROHIBITED_PRACTICES = Object.freeze([
|
|
|
51
51
|
Object.freeze({
|
|
52
52
|
id: "social-scoring",
|
|
53
53
|
article: "Art. 5(1)(c)",
|
|
54
|
-
title: "Social scoring
|
|
54
|
+
title: "Social scoring of natural persons",
|
|
55
55
|
description: "AI systems for the evaluation or classification of natural persons over a certain period of time based on their social behaviour or known, inferred or predicted personal or personality characteristics, leading to detrimental or unfavourable treatment that is unjustified or disproportionate.",
|
|
56
56
|
examples: Object.freeze([
|
|
57
57
|
"General-purpose social-credit ranking by a state agency",
|
|
@@ -151,9 +151,13 @@ function classify(systemDescription) {
|
|
|
151
151
|
if (systemDescription.targetsVulnerableGroup === true) {
|
|
152
152
|
hits.push("exploit-vulnerabilities");
|
|
153
153
|
}
|
|
154
|
-
// (c) social scoring
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
// (c) social scoring — Art. 5(1)(c) of the adopted Regulation (EU)
|
|
155
|
+
// 2024/1689 prohibits social scoring by ANY actor. The "by public
|
|
156
|
+
// authorities or on their behalf" limitation from the 2021 Commission
|
|
157
|
+
// proposal was dropped in the final text, so the practice is flagged on the
|
|
158
|
+
// purpose alone, not gated on deployerType (which would under-classify a
|
|
159
|
+
// private-actor social-scoring system as minimal-risk).
|
|
160
|
+
if (systemDescription.purpose === "social-scoring") {
|
|
157
161
|
hits.push("social-scoring");
|
|
158
162
|
}
|
|
159
163
|
// (d) predictive policing on profiling alone
|
package/lib/crypto-xwing.js
CHANGED
|
@@ -69,11 +69,29 @@ function _x25519Public(sk) {
|
|
|
69
69
|
var spki = nodeCrypto.createPublicKey(key).export({ format: "der", type: "spki" });
|
|
70
70
|
return spki.subarray(spki.length - X25519_LEN);
|
|
71
71
|
}
|
|
72
|
+
// draft-connolly-cfrg-xwing-kem uses X25519 exactly as RFC 7748 specifies it,
|
|
73
|
+
// WITHOUT the optional RFC 7748 section 6.1 abort on an all-zero shared secret.
|
|
74
|
+
// Decapsulation must be uniform implicit-rejection: a hostile low-order X25519
|
|
75
|
+
// ephemeral in the ciphertext must NOT be able to signal validity by making
|
|
76
|
+
// decapsulate throw (the documented "yields a different secret rather than an
|
|
77
|
+
// error" contract, and a defense against a decapsulation oracle / crash-on-
|
|
78
|
+
// crafted-input DoS). OpenSSL (the X25519 backing this seam) DOES abort that
|
|
79
|
+
// derivation, so translate its error back into the all-zero shared secret RFC
|
|
80
|
+
// 7748 X25519 yields for a low-order input point. The combiner still binds ctX
|
|
81
|
+
// and pkX and the ML-KEM-768 leg still protects the result, so collapsing the
|
|
82
|
+
// classical leg costs only the classical half of the "secure if either holds"
|
|
83
|
+
// guarantee. A short/malformed key fails earlier at createPublicKey with a
|
|
84
|
+
// different code and still propagates.
|
|
72
85
|
function _x25519Shared(sk, pk) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
})
|
|
86
|
+
try {
|
|
87
|
+
return nodeCrypto.diffieHellman({
|
|
88
|
+
privateKey: nodeCrypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, sk]), format: "der", type: "pkcs8" }),
|
|
89
|
+
publicKey: nodeCrypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, pk]), format: "der", type: "spki" }),
|
|
90
|
+
});
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e && e.code === "ERR_OSSL_FAILED_DURING_DERIVATION") return Buffer.alloc(X25519_LEN);
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
function _shake256(buf, outLen) { return nodeCrypto.createHash("shake256", { outputLength: outLen }).update(buf).digest(); }
|
package/lib/gdpr-ropa.js
CHANGED
|
@@ -129,7 +129,14 @@ function create(opts) {
|
|
|
129
129
|
registeredAt: existing.registeredAt,
|
|
130
130
|
lastUpdatedAt: now(),
|
|
131
131
|
});
|
|
132
|
-
|
|
132
|
+
// Validate whenever the patch TOUCHES legalBasis — keyed on own-property
|
|
133
|
+
// presence, not truthiness. A falsy-but-invalid value ("" / 0 / false /
|
|
134
|
+
// undefined) is still an invalid legal basis and must be rejected with the
|
|
135
|
+
// same rigor register() applies; a `patch.legalBasis &&` guard would skip
|
|
136
|
+
// the enum check for those values and silently corrupt a required
|
|
137
|
+
// Article 30 field.
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(patch, "legalBasis") &&
|
|
139
|
+
!Object.prototype.hasOwnProperty.call(VALID_LEGAL_BASES, merged.legalBasis)) {
|
|
133
140
|
throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
|
|
134
141
|
"gdpr.ropa.update: legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
|
|
135
142
|
}
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -150,6 +150,8 @@ var safeSmtp = require("./safe-smtp");
|
|
|
150
150
|
var validateOpts = require("./validate-opts");
|
|
151
151
|
var guardSmtpCommand = require("./guard-smtp-command");
|
|
152
152
|
var guardDomain = require("./guard-domain");
|
|
153
|
+
var guardCidr = require("./guard-cidr");
|
|
154
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
153
155
|
var mailServerRateLimit = require("./mail-server-rate-limit");
|
|
154
156
|
var mailServerTls = require("./mail-server-tls");
|
|
155
157
|
var mailServerNet = require("./mail-server-net");
|
|
@@ -196,6 +198,42 @@ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
|
|
|
196
198
|
var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
|
|
197
199
|
var RE_SIZE = /SIZE=(\d+)/i;
|
|
198
200
|
|
|
201
|
+
// A relayAllowedFor entry's `cidr` must be an `<ip>/<prefix>` range so the
|
|
202
|
+
// relay-authorization decision can match the connecting peer against it via
|
|
203
|
+
// b.ssrfGuard.cidrContains (the same range arithmetic the HTTP
|
|
204
|
+
// b.middleware.networkAllowlist fence uses). Shape-validated by composing
|
|
205
|
+
// b.guardCidr.validate rather than a hand-rolled parse: a mask is REQUIRED
|
|
206
|
+
// (a bare IP never matches in cidrContains, so it is refused at boot rather
|
|
207
|
+
// than silently disabling the entry), reserved / private ranges are ALLOWED
|
|
208
|
+
// (a relay allowlist legitimately names 10.0.0.0/8 and friends), and a
|
|
209
|
+
// non-canonical-but-functional network address (host bits set) is audited,
|
|
210
|
+
// not rejected — cidrContains masks it off at match time.
|
|
211
|
+
var _RELAY_CIDR_OPTS = Object.freeze({
|
|
212
|
+
requireMaskPolicy: "reject-bare-ip",
|
|
213
|
+
reservedRangesPolicy: "allow",
|
|
214
|
+
ipv4MappedIpv6Policy: "allow",
|
|
215
|
+
networkAlignmentPolicy: "audit",
|
|
216
|
+
family: "either",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// _normalizeRelayCidr — fold a DOTTED IPv4-mapped IPv6 relay CIDR
|
|
220
|
+
// (::ffff:a.b.c.d/N, N in 96..128) to its plain IPv4 CIDR (a.b.c.d/(N-96)).
|
|
221
|
+
// guardCidr.validate parses hex-group IPv6 but not the dotted-mapped spelling,
|
|
222
|
+
// so an operator naming a mapped range that way would be refused at boot even
|
|
223
|
+
// though cidrContains accepts it. Folding to plain IPv4 both validates AND
|
|
224
|
+
// makes the entry match every peer form: a genuine IPv4 peer directly, and an
|
|
225
|
+
// IPv4-mapped peer via the _isRelayAllowed fold. Storing the mapped CIDR as-is
|
|
226
|
+
// would instead match a mapped peer but NOT a genuine IPv4 peer (the inverse
|
|
227
|
+
// asymmetry). Every other spelling (plain IPv4, hex-group IPv6) is unchanged.
|
|
228
|
+
function _normalizeRelayCidr(cidr) {
|
|
229
|
+
if (typeof cidr !== "string") return cidr;
|
|
230
|
+
var m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,3})$/i.exec(cidr);
|
|
231
|
+
if (!m) return cidr;
|
|
232
|
+
var prefix = parseInt(m[2], 10);
|
|
233
|
+
if (prefix < 96 || prefix > 128) return cidr; // outside the ::ffff:0:0/96 block — a genuine IPv6 range
|
|
234
|
+
return m[1] + "/" + (prefix - 96);
|
|
235
|
+
}
|
|
236
|
+
|
|
199
237
|
// Map the b.mail.inbound.verify verdict to the DATA-phase gate action.
|
|
200
238
|
// The sender's published DMARC policy drives it (RFC 7489 §6.3 p= /
|
|
201
239
|
// §6.6.2 disposition): reject → refuse at the wire; quarantine →
|
|
@@ -319,6 +357,25 @@ function create(opts) {
|
|
|
319
357
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
320
358
|
"mail.server.mx.create: relayAllowedFor must be an array if provided");
|
|
321
359
|
}
|
|
360
|
+
// Every relay-allowlist entry MUST carry a valid `<ip>/<prefix>` CIDR:
|
|
361
|
+
// relay is granted only to a peer whose address falls inside an allowlisted
|
|
362
|
+
// range. Refuse a malformed / mask-less entry at boot so an operator typo
|
|
363
|
+
// can't silently leave the relay decision mis-scoped (open relay is the
|
|
364
|
+
// failure this closes — pre-fix any non-empty relayAllowedFor admitted
|
|
365
|
+
// every peer regardless of source address).
|
|
366
|
+
if (Array.isArray(opts.relayAllowedFor)) {
|
|
367
|
+
for (var __ri = 0; __ri < opts.relayAllowedFor.length; __ri += 1) {
|
|
368
|
+
var __re = opts.relayAllowedFor[__ri];
|
|
369
|
+
var __reOk = __re && typeof __re === "object" && !Array.isArray(__re) &&
|
|
370
|
+
guardCidr.validate(_normalizeRelayCidr(__re.cidr), _RELAY_CIDR_OPTS).ok;
|
|
371
|
+
if (!__reOk) {
|
|
372
|
+
throw new MailServerMxError("mail-server-mx/bad-relay-cidr",
|
|
373
|
+
"mail.server.mx.create: relayAllowedFor[" + __ri + "] must be an object with a " +
|
|
374
|
+
"valid CIDR string (e.g. { cidr: \"10.0.0.0/8\", scope: \"internal\" }); relay is " +
|
|
375
|
+
"granted only to peers whose source address falls inside an allowlisted range");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
322
379
|
|
|
323
380
|
var greeting = opts.greeting || DEFAULT_GREETING;
|
|
324
381
|
var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
|
|
@@ -326,7 +383,11 @@ function create(opts) {
|
|
|
326
383
|
var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
|
|
327
384
|
var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
|
|
328
385
|
var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
|
|
329
|
-
var relayAllowedFor = opts.relayAllowedFor || []
|
|
386
|
+
var relayAllowedFor = (opts.relayAllowedFor || []).map(function (__e) {
|
|
387
|
+
return (__e && typeof __e === "object" && !Array.isArray(__e))
|
|
388
|
+
? Object.assign({}, __e, { cidr: _normalizeRelayCidr(__e.cidr) })
|
|
389
|
+
: __e;
|
|
390
|
+
});
|
|
330
391
|
var profile = opts.profile || "strict";
|
|
331
392
|
// SMTPUTF8 (RFC 6531) — single switch threaded end-to-end. The MX
|
|
332
393
|
// listener doesn't advertise SMTPUTF8 to the peer regardless, so
|
|
@@ -1195,12 +1256,37 @@ function create(opts) {
|
|
|
1195
1256
|
return profile === "strict" || profile === "balanced";
|
|
1196
1257
|
}
|
|
1197
1258
|
|
|
1198
|
-
function _isRelayAllowed(
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1259
|
+
function _isRelayAllowed(remoteAddress, _rcptTo) {
|
|
1260
|
+
// Relay is admitted ONLY when the connecting peer's source address
|
|
1261
|
+
// falls inside one of the operator's allowlisted CIDR ranges — the
|
|
1262
|
+
// same range arithmetic (b.ssrfGuard.cidrContains) the HTTP
|
|
1263
|
+
// b.middleware.networkAllowlist fence uses. Every entry's `cidr` was
|
|
1264
|
+
// shape-validated at create() time; a peer outside every range (or a
|
|
1265
|
+
// non-string / empty peer address) is refused, so a misconfigured
|
|
1266
|
+
// relayAllowedFor fails closed instead of turning the listener into an
|
|
1267
|
+
// open relay. `scope` is an operator-facing annotation on the entry;
|
|
1268
|
+
// the network boundary is the authorization control.
|
|
1202
1269
|
if (relayAllowedFor.length === 0) return false;
|
|
1203
|
-
return
|
|
1270
|
+
if (typeof remoteAddress !== "string" || remoteAddress.length === 0) return false;
|
|
1271
|
+
// Node reports an IPv4 client as an IPv4-mapped IPv6 address
|
|
1272
|
+
// (::ffff:a.b.c.d) when the listener binds the IPv6 wildcard `::` (the
|
|
1273
|
+
// common dual-stack deployment). cidrContains refuses a mixed-family
|
|
1274
|
+
// compare, so a documented IPv4 relay CIDR (10.0.0.0/8) would deny every
|
|
1275
|
+
// intended IPv4 client on that listener. Fold the mapped form to its
|
|
1276
|
+
// IPv4 dotted address (ssrfGuard.canonicalizeHost, which folds only the
|
|
1277
|
+
// ::ffff:0:0/96 block) and match EITHER the peer as reported OR the
|
|
1278
|
+
// folded form — so an IPv4 CIDR matches a mapped peer and an IPv6 CIDR
|
|
1279
|
+
// still matches a genuine IPv6 peer.
|
|
1280
|
+
var canonPeer;
|
|
1281
|
+
try { canonPeer = ssrfGuard.canonicalizeHost(remoteAddress); }
|
|
1282
|
+
catch (_e) { canonPeer = remoteAddress; }
|
|
1283
|
+
for (var i = 0; i < relayAllowedFor.length; i += 1) {
|
|
1284
|
+
var entry = relayAllowedFor[i];
|
|
1285
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1286
|
+
if (ssrfGuard.cidrContains(entry.cidr, remoteAddress)) return true;
|
|
1287
|
+
if (canonPeer !== remoteAddress && ssrfGuard.cidrContains(entry.cidr, canonPeer)) return true;
|
|
1288
|
+
}
|
|
1289
|
+
return false;
|
|
1204
1290
|
}
|
|
1205
1291
|
}
|
|
1206
1292
|
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:8b687625-0aed-45e7-a4c6-fd40648ecbeb",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-18T06:24:31.795Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.17.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.17.11",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.17.
|
|
25
|
+
"version": "0.17.11",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.17.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.17.11",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.17.
|
|
57
|
+
"ref": "@blamejs/core@0.17.11",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|