Skip to content

Commit 679d72c

Browse files
security(origin-gate): make the reserved-prefix set registry-exact, both ways
Adversarial verification of the previous two commits found that the prefix set was a selection presented as "the remaining" ones, and that two of its boundaries were untested. Both are fixed here. Under-blocking closed — these carry "Globally Reachable: False" and were still accepted: ::ffff:0:0/96 IPv4-mapped (RFC 4291) — accepted whenever the embedded IPv4 was public; a mapped literal is not a routable IPv6 origin regardless of what it wraps 2001::/23 IETF Protocol Assignments (RFC 2928) — the unassigned remainder (2001:5::/32, 2001:100::/24, …) was reachable 2001::/23 is refused minus the sub-blocks the registry re-delegates as individually reachable: 2001::/32 (Teredo), 2001:1::1-3/128 (PCP/TURN/DNS-SD anycast, exactly those three addresses), 2001:3::/32 (AMT), 2001:4:112::/48 (AS112-v6), 2001:20::/28 (ORCHIDv2), 2001:30::/28 (Drone Remote ID). This subsumes the separate 2001:2::/48 and 2001:10::/28 predicates. Test gap closed — two over-blocking mutations survived the whole suite: 3fff::/20 widened to /16 → was green, now 189/1 (added 3fff:1000::1 accept) 2001:2::/48 widened → was green, now 189/1 (added 2001:2:1::1 reject) Both edges were "adjacent" cases that sat outside the widened range and so could not discriminate it. The suite now kills all 8 mutations tried, in both directions: removing a rule → red, removing a carve-out → red. CHANGELOG entries added for this and the two preceding commits; the repo requires one per notable change and all three were missing. The entry names the Forwardable-vs-Globally-Reachable column trap explicitly, since that is what produced the ORCHIDv2 error in the first place. 190/190.
1 parent d064da4 commit 679d72c

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,37 @@ Versionierung nach [SemVer](https://semver.org/lang/de/).
3030
dokumentierte Luecke, ueber die eine private/link-local IPv4 als
3131
Uebergangs-IPv6-Origin an der Allowlist vorbeigeschmuggelt werden konnte.
3232
Regressionstests in `tests/security/production-boundary.test.js`.
33+
- Origin-Allowlist, registry-genaue Fassung (`src/security/header-values.js`):
34+
Reservierte IPv6-Praefixe werden jetzt als Praefix abgelehnt, wenn ihre Zeile
35+
in der IANA-Registry `Globally Reachable: False` traegt — `::ffff:0:0/96`
36+
(RFC 4291), `100::/64` (RFC 6666), `100:0:0:1::/64` (RFC 9780),
37+
`64:ff9b:1::/48` (RFC 8215) und `2001::/23` (RFC 2928) abzueglich seiner
38+
einzeln als erreichbar registrierten Unterbloecke, dazu `3fff::/20`
39+
(RFC 9637) und `5f00::/16` (RFC 9602). `64:ff9b:1::/48` schliesst damit den
40+
Rest, den reines Dekodieren nicht abdecken kann: RFC 8215 legt die IPv4 an
41+
einen netzspezifischen Offset.
42+
**Bewusst NICHT abgelehnt** (Registry sagt `Globally Reachable: True`):
43+
`64:ff9b::/96`, `2001::/32` (Teredo), `2001:1::1-3/128`, `2001:3::/32`,
44+
`2001:4:112::/48`, `2001:20::/28` (ORCHIDv2), `2001:30::/28`. Wo ein solches
45+
Praefix eine IPv4 einbettet, greift stattdessen die Dekodierung — die engere
46+
und korrekte Kontrolle.
47+
Achtung bei kuenftigen Aenderungen: die Registry-CSV fuehrt `Forwardable` und
48+
`Globally Reachable` als getrennte Spalten, die sich fuer `100::/64`,
49+
`2001:2::/48`, `5f00::/16` und `64:ff9b:1::/48` unterscheiden. Massgeblich ist
50+
`Globally Reachable` (Spaltenindex 8).
51+
*Verhaltensaenderung fuer Deployer:* Approved-Origins, die auf eines dieser
52+
Praefixe zeigen, werden ab jetzt abgelehnt. Legitime Endpunkte sind davon
53+
nicht betroffen; die Ablehnung ist in beide Richtungen testgedeckt
54+
(Ablehn- und Annahme-Faelle je an der Praefixgrenze).
55+
- Lokale Gates unter Windows lauffaehig (`scripts/npm-invocation.js`, neu):
56+
`toolchain:check` und `audit:ci` starteten npm als blosses `npm`. Unter
57+
Windows heisst der Einstiegspunkt `npm.cmd`, und seit der Haertung zu
58+
CVE-2024-27980 verweigert Node den Start von `.cmd`/`.bat` ohne `shell: true`.
59+
Damit liefen **zwei der fuenf lokalen Gates auf dem Entwicklungsrechner
60+
ueberhaupt nicht** — die Aussage `audit:ci 0/0` war nur in der CI pruefbar,
61+
was die LOCAL-FIRST-Annahme aushebelt. Der neue Helfer startet npms eigenen
62+
JS-Einstiegspunkt mit dem laufenden Node-Binary, statt eine Shell zu oeffnen;
63+
`verify-local.js` nutzt ihn ebenfalls und verliert seinen `shell: true`-Zweig.
3364
- Supply-Chain / `audit:ci`-Gate: vier Advisories im Markdown-Dev-Tooling
3465
geschlossen (0 high/critical). `markdownlint-cli2` auf `0.23.2` angehoben
3566
(gepatchtes `js-yaml` 5.2.2 gegen die YAML-DoS-CVEs) und `overrides` fuer

src/security/header-values.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,42 @@ function embeddedIpv4sFromIpv6(groups) {
499499
return candidates;
500500
}
501501

502+
/**
503+
* Is the literal inside 2001::/23 ("IETF Protocol Assignments", RFC 2928)?
504+
*
505+
* The /23 as a whole carries "Globally Reachable: False", but the registry then
506+
* re-delegates several sub-blocks inside it that ARE individually reachable.
507+
* Refusing the /23 wholesale would over-block those; accepting the /23 wholesale
508+
* leaves the unassigned remainder (2001:5::/32, 2001:100::/24, …) reachable as
509+
* an approved endpoint. So: refuse the /23 except the registered-True carve-outs.
510+
*
511+
* This also subsumes two prefixes that would otherwise need their own line —
512+
* 2001:2::/48 (benchmarking, RFC 5180, False) and 2001:10::/28 (deprecated
513+
* ORCHID, RFC 4843, assignment terminated 2014-03).
514+
*/
515+
function isReservedIetfProtocolAssignment(groups) {
516+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
517+
if (g0 !== 0x2001 || (g1 & 0xfe00) !== 0) return false; // outside 2001::/23
518+
519+
// 2001::/32 TEREDO (RFC 4380) — Globally Reachable: True. The IPv4 addresses
520+
// it embeds are decoded by embeddedIpv4sFromIpv6 instead.
521+
if (g1 === 0x0000) return false;
522+
523+
// 2001:1::1/128 PCP, ::2/128 TURN, ::3/128 DNS-SD SRP anycast — each True.
524+
// Only these three addresses, not the surrounding 2001:1::/32.
525+
if (g1 === 0x0001) {
526+
const restZero = g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0;
527+
return !(restZero && (g7 === 1 || g7 === 2 || g7 === 3));
528+
}
529+
530+
if (g1 === 0x0003) return false; // 2001:3::/32 AMT (RFC 7450) — True
531+
if (g1 === 0x0004 && g2 === 0x0112) return false; // 2001:4:112::/48 AS112-v6 (RFC 7535) — True
532+
if ((g1 & 0xfff0) === 0x0020) return false; // 2001:20::/28 ORCHIDv2 (RFC 7343) — True
533+
if ((g1 & 0xfff0) === 0x0030) return false; // 2001:30::/28 Drone Remote ID (RFC 9374) — True
534+
535+
return true;
536+
}
537+
502538
function isNonPublicAddressLiteral(hostname) {
503539
const ipv4 = parseCanonicalIpv4(hostname);
504540
if (ipv4) return isNonPublicIpv4(ipv4);
@@ -510,7 +546,7 @@ function isNonPublicAddressLiteral(hostname) {
510546
if (isNonPublicIpv4(embedded)) return true;
511547
}
512548

513-
const [g0, g1, g2, g3] = groups;
549+
const [g0, g1, g2, g3, g4, g5] = groups;
514550
const isUnspecified = groups.every((group) => group === 0);
515551
const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
516552
return (
@@ -538,14 +574,14 @@ function isNonPublicAddressLiteral(hostname) {
538574
// Source: IANA IPv6 Special-Purpose Address Registry, column
539575
// "Globally Reachable" (NOT the adjacent "Forwardable" column, which
540576
// differs for 100::/64, 2001:2::/48, 5f00::/16 and 64:ff9b:1::/48).
577+
// ::ffff:0:0/96 IPv4-mapped (RFC 4291) — registry-False. The embedded IPv4
578+
// is decoded above, but a mapped literal is not a routable IPv6 origin even
579+
// when the IPv4 inside it is public, so the prefix is refused outright.
580+
(g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) ||
541581
(g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 0) || // 100::/64 discard-only (RFC 6666)
542582
(g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 1) || // 100:0:0:1::/64 dummy prefix (RFC 9780)
543583
(g0 === 0x0064 && g1 === 0xff9b && g2 === 0x0001) || // 64:ff9b:1::/48 NAT64 local-use (RFC 8215)
544-
(g0 === 0x2001 && g1 === 0x0002 && g2 === 0) || // 2001:2::/48 benchmarking (RFC 5180)
545-
// 2001:10::/28 — assignment terminated 2014-03, registry row carries no
546-
// "Globally Reachable" value at all; a deprecated, unassigned block is not
547-
// a valid endpoint either.
548-
(g0 === 0x2001 && (g1 & 0xfff0) === 0x0010) || // ORCHID, deprecated (RFC 4843)
584+
isReservedIetfProtocolAssignment(groups) || // 2001::/23 minus its reachable carve-outs
549585
(g0 === 0x3fff && (g1 & 0xf000) === 0) || // 3fff::/20 documentation (RFC 9637)
550586
g0 === 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9602)
551587
);

tests/security/production-boundary.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,15 @@ test('remaining IANA special-purpose IPv6 prefixes are refused outright', () =>
137137
'https://[100::1]',
138138
'https://[100:0:0:1::1]', // 100:0:0:1::/64 dummy prefix (RFC 9780)
139139
'https://[64:ff9b:1::1]', // NAT64 local-use (RFC 8215) — the documented residual
140+
'https://[::ffff:808:808]', // ::ffff:0:0/96 IPv4-mapped, PUBLIC embedded IPv4
140141
'https://[2001:2::1]', // benchmarking (RFC 5180)
142+
'https://[2001:2:1::1]', // still inside 2001::/23, outside the /48 — pins the /48-vs-/32 edge
141143
'https://[2001:10::1]', // ORCHID, deprecated (RFC 4843)
142144
'https://[2001:1f:ffff::1]', // 2001:10::/28 upper edge
145+
'https://[2001:5::1]', // unassigned remainder of 2001::/23 (RFC 2928)
146+
'https://[2001:100::1]', // unassigned remainder, upper half of the /23
147+
'https://[2001:1ff:ffff::1]', // 2001::/23 upper edge
148+
'https://[2001:1::4]', // 2001:1::/32 outside the three reachable /128 anycasts
143149
'https://[3fff::1]', // documentation (RFC 9637)
144150
'https://[3fff:fff:ffff::1]', // 3fff::/20 upper edge
145151
'https://[5f00::1]', // SRv6 SIDs (RFC 9602)
@@ -163,10 +169,17 @@ test('address space adjacent to the special-purpose prefixes is not over-blocked
163169
'https://[64:ff9b:2::1]', // outside the NAT64 local-use /48
164170
'https://[64:ff9b::808:808]', // NAT64 well-known, public embedded IPv4 — reachable
165171
'https://[2001:3::1]', // AMT (RFC 7450) — Globally Reachable: True
172+
'https://[2001:4:112::1]', // AS112-v6 (RFC 7535) — True
166173
'https://[2001:20::1]', // ORCHIDv2 (RFC 7343) — Globally Reachable: True
167174
'https://[2001:2f:ffff::1]', // ORCHIDv2 upper edge — must stay accepted
168175
'https://[2001:30::1]', // Drone Remote ID (RFC 9374) — Globally Reachable: True
169176
'https://[2001:1::1]', // Port Control Protocol Anycast (RFC 7723) — True
177+
'https://[2001:1::2]', // TURN Anycast (RFC 8155) — True
178+
'https://[2001:1::3]', // DNS-SD SRP Anycast (RFC 9665) — True
179+
'https://[2002:808:808::]', // 6to4 with public embedded IPv4 — outside the /23
180+
'https://[2000::1]', // below 2001::/23
181+
'https://[2001:200::1]', // just above 2001::/23 (g1 = 0x0200)
182+
'https://[3fff:1000::1]', // inside 3fff::/16 but OUTSIDE the /20 — pins the /20-vs-/16 edge
170183
'https://[4000::1]', // outside 3fff::/20
171184
'https://[3ffe::1]', // below 3fff::/20
172185
'https://[5f01::1]', // outside 5f00::/16

0 commit comments

Comments
 (0)