haechi 1.3.0 → 1.3.1

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.
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { isUtf8 } from "node:buffer";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { once } from "node:events";
7
8
  import { inspectResponseStream } from "../stream-filter/index.mjs";
8
9
 
9
10
  export const DEFAULT_PROXY_PORT = 11016;
@@ -107,6 +108,19 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
107
108
  const metrics = runtime.metrics ?? noopMetrics();
108
109
  const logger = createLogger(config.logging?.format ?? "text");
109
110
 
111
+ // P0-CR-001 — the upstream header forward policy, derived ONCE from config.
112
+ // gatewayConsumedAuthorization is true whenever the gateway authenticates the
113
+ // CLIENT (auth.provider !== "none"): the request's Authorization is then the
114
+ // gateway credential Haechi consumed and must NOT be forwarded to the model
115
+ // upstream. With auth.provider "none" the client's Authorization is the
116
+ // upstream provider key and IS forwarded. extraHeaders is the operator's
117
+ // additive target.forwardHeaders allowlist (validated lowercase in
118
+ // normalizeConfig); it can only widen, never override the always-drop set.
119
+ const forwardPolicy = {
120
+ gatewayConsumedAuthorization: (config.auth?.provider ?? "none") !== "none",
121
+ extraHeaders: new Set(config.target?.forwardHeaders ?? [])
122
+ };
123
+
110
124
  // WS4-B backpressure: a configurable global max-in-flight ceiling. 0 (default)
111
125
  // disables it, preserving 1.1 behavior. When > 0 and the live count is at the
112
126
  // ceiling, a NEW non-exempt request is rejected 503 + Retry-After BEFORE auth
@@ -237,7 +251,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
237
251
 
238
252
  if (isStreamingRequest(json, routeContext)) {
239
253
  if (config.streaming.requestMode === "inspect") {
240
- await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics });
254
+ await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
241
255
  return;
242
256
  }
243
257
 
@@ -259,11 +273,24 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
259
273
  request,
260
274
  body,
261
275
  timeoutMs: config.limits.upstreamTimeoutMs,
262
- metrics
276
+ metrics,
277
+ forwardPolicy
278
+ });
279
+ // P1-CR-003 — sanitize response headers (strip the upstream's
280
+ // content-encoding/content-length/transfer/hop-by-hop) on this path
281
+ // too: Node fetch() auto-decompressed the body, so the original
282
+ // compressed headers would now be wrong. P1-CR-004 — TRUE bounded
283
+ // streaming pass-through: pipe the upstream body to the client with a
284
+ // running byte cap instead of buffering the whole response.
285
+ response.writeHead(upstreamResponse.status, sanitizeResponseHeaders(upstreamResponse));
286
+ await pipeUpstreamBodyBounded({
287
+ upstreamResponse,
288
+ response,
289
+ maxBytes: streamingPassThroughMaxBytes(config),
290
+ logger,
291
+ metrics,
292
+ correlationId
263
293
  });
264
- const { body: rawBody } = await readUpstreamBody(upstreamResponse);
265
- response.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers.entries()));
266
- response.end(rawBody);
267
294
  return;
268
295
  }
269
296
 
@@ -301,7 +328,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
301
328
  request,
302
329
  body: JSON.stringify(result.payload),
303
330
  timeoutMs: config.limits.upstreamTimeoutMs,
304
- metrics
331
+ metrics,
332
+ forwardPolicy
305
333
  });
306
334
 
307
335
  const forwarded = await maybeProtectResponse({
@@ -633,7 +661,7 @@ async function recordAuthDenied({ runtime, routeContext, reason, correlationId =
633
661
  });
634
662
  }
635
663
 
636
- async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {}, metrics = null }) {
664
+ async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {}, metrics = null, forwardPolicy = {} }) {
637
665
  const { haechi, config } = runtime;
638
666
  const requestMode = config.policy.mode ?? config.mode;
639
667
 
@@ -674,7 +702,8 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
674
702
  request,
675
703
  body: JSON.stringify(requestResult.payload),
676
704
  timeoutMs: config.limits.upstreamTimeoutMs,
677
- metrics
705
+ metrics,
706
+ forwardPolicy
678
707
  });
679
708
 
680
709
  const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
@@ -687,7 +716,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
687
716
  maxMatchBytes: config.streaming.maxMatchBytes
688
717
  });
689
718
 
690
- response.writeHead(upstreamResponse.status, streamingResponseHeaders(upstreamResponse));
719
+ response.writeHead(upstreamResponse.status, sanitizeResponseHeaders(upstreamResponse));
691
720
 
692
721
  const { blocked, summary } = await inspectResponseStream({
693
722
  source: upstreamResponse.body ?? emptyAsyncIterable(),
@@ -708,13 +737,97 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
708
737
  response.end();
709
738
  }
710
739
 
711
- function streamingResponseHeaders(upstreamResponse) {
740
+ // P1-CR-003 — the SINGLE centralized response-header sanitizer used on EVERY
741
+ // response path (pass-through, forwarded/unprotected, protected, streaming).
742
+ // Node fetch() auto-decompresses gzip/br/deflate, so the upstream's original
743
+ // content-encoding/content-length now describe the WIRE bytes Haechi no longer
744
+ // emits — forwarding them makes a downstream client see "content-encoding: gzip"
745
+ // on plain bytes and fail with "incorrect header check". transfer-encoding and
746
+ // the hop-by-hop control headers (RFC 7230 §6.1) likewise describe the upstream
747
+ // hop, not Haechi's connection to the client, so they are stripped too. A
748
+ // correct content-length is re-set ONLY by a caller that emits a fully-buffered
749
+ // body (transformedJsonHeaders / the buffered-body helper below); a streamed or
750
+ // raw-piped body intentionally carries no content-length.
751
+ const RESPONSE_HOP_BY_HOP_HEADERS = [
752
+ "content-encoding",
753
+ "content-length",
754
+ "transfer-encoding",
755
+ "connection",
756
+ "keep-alive",
757
+ "te",
758
+ "trailer",
759
+ "upgrade",
760
+ "proxy-authenticate"
761
+ ];
762
+
763
+ function sanitizeResponseHeaders(upstreamResponse) {
712
764
  const headers = Object.fromEntries(upstreamResponse.headers.entries());
713
- delete headers["content-length"];
714
- delete headers["content-encoding"];
765
+ for (const name of RESPONSE_HOP_BY_HOP_HEADERS) {
766
+ delete headers[name];
767
+ }
715
768
  return headers;
716
769
  }
717
770
 
771
+ // P1-CR-004 — the byte cap for the streaming pass-through path. Reuse
772
+ // responseProtection.maxBytes (the existing hard response-size cap) so a single
773
+ // dial governs all raw upstream-body reads; falls back to a 1 MiB default for a
774
+ // hand-built config without responseProtection.
775
+ function streamingPassThroughMaxBytes(config) {
776
+ const cap = config.responseProtection?.maxBytes;
777
+ return typeof cap === "number" && cap > 0 ? cap : 1048576;
778
+ }
779
+
780
+ // P1-CR-004 — TRUE bounded streaming pass-through. Pipe the upstream body to the
781
+ // client response as it arrives (real streaming) while counting bytes; if the
782
+ // running total exceeds maxBytes, abort: cancel the upstream reader and destroy
783
+ // the client response so a long-lived or malicious stream cannot hold memory or
784
+ // the connection open unbounded. Bytes already written cannot be retracted, so
785
+ // this caps total memory/throughput, not the already-flushed prefix.
786
+ async function pipeUpstreamBodyBounded({ upstreamResponse, response, maxBytes, logger = null, metrics = null, correlationId = null }) {
787
+ if (!upstreamResponse.body) {
788
+ response.end();
789
+ return;
790
+ }
791
+
792
+ const reader = upstreamResponse.body.getReader();
793
+ let received = 0;
794
+ try {
795
+ while (true) {
796
+ const { done, value } = await reader.read();
797
+ if (done) {
798
+ break;
799
+ }
800
+ received += value.byteLength;
801
+ if (maxBytes && received > maxBytes) {
802
+ // Over the cap: stop reading upstream and tear down the client write so
803
+ // the oversize stream is bounded (fail-closed on size).
804
+ void cancelReader(reader);
805
+ metrics?.increment("haechi_response_stream_truncated_total");
806
+ logger?.error("proxy_stream_pass_through_too_large", {
807
+ correlationId,
808
+ maxBytes
809
+ });
810
+ if (!response.writableEnded) {
811
+ response.destroy();
812
+ }
813
+ return;
814
+ }
815
+ // Respect downstream backpressure: stop pulling upstream until the client
816
+ // socket has drained.
817
+ const ok = response.write(Buffer.from(value));
818
+ if (!ok) {
819
+ await once(response, "drain");
820
+ }
821
+ }
822
+ response.end();
823
+ } catch (error) {
824
+ void cancelReader(reader);
825
+ if (!response.writableEnded) {
826
+ response.destroy();
827
+ }
828
+ }
829
+ }
830
+
718
831
  function nodeResponseSink(response) {
719
832
  return {
720
833
  write(text) {
@@ -751,20 +864,42 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
751
864
  }
752
865
 
753
866
  async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [], metrics = null }) {
754
- const headers = Object.fromEntries(upstreamResponse.headers.entries());
867
+ // P1-CR-003 content-encoding is read off the RAW upstream headers (before
868
+ // sanitation) for the compressed-response gate; the headers RETURNED to the
869
+ // client are always the sanitized set (no stale compression/length metadata).
870
+ const rawHeaders = Object.fromEntries(upstreamResponse.headers.entries());
871
+ const headers = sanitizeResponseHeaders(upstreamResponse);
755
872
 
756
873
  if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
757
- const { body: rawBody } = await readUpstreamBody(upstreamResponse);
874
+ // P1-CR-004 apply the same byte cap to this raw upstream-body read so an
875
+ // unprotected/forwarded response cannot be buffered unbounded. Fail closed
876
+ // (502) when the upstream body exceeds the cap.
877
+ const passThroughMax = streamingPassThroughMaxBytes(runtime.config);
878
+ const { body: rawBody, tooLarge } = await readUpstreamBody(upstreamResponse, { maxBytes: passThroughMax });
879
+ if (tooLarge) {
880
+ metrics?.increment("haechi_response_stream_truncated_total");
881
+ return {
882
+ decision: "response_unprotected_blocked",
883
+ status: 502,
884
+ headers: { "content-type": "application/json" },
885
+ body: Buffer.from(`${JSON.stringify({
886
+ error: "haechi_response_too_large",
887
+ reason: "response_body_too_large",
888
+ message: `Response body exceeds responseProtection.maxBytes (${passThroughMax})`
889
+ }, null, 2)}\n`)
890
+ };
891
+ }
758
892
  return {
759
893
  status: upstreamResponse.status,
760
- headers,
894
+ // Re-set a correct content-length: this is a fully-buffered body.
895
+ headers: { ...headers, "content-length": String(rawBody.byteLength) },
761
896
  body: rawBody,
762
897
  decision: "forwarded"
763
898
  };
764
899
  }
765
900
 
766
901
  const responsePolicy = runtime.config.responseProtection;
767
- const contentEncoding = headers["content-encoding"] ?? "";
902
+ const contentEncoding = rawHeaders["content-encoding"] ?? "";
768
903
  const bodyRead = await readUpstreamBody(upstreamResponse, { maxBytes: responsePolicy.maxBytes });
769
904
 
770
905
  if (bodyRead.tooLarge) {
@@ -916,12 +1051,12 @@ function restoreTokens(value, tokenValues) {
916
1051
  return value;
917
1052
  }
918
1053
 
919
- async function forward({ upstream, request, body, timeoutMs = null, metrics = null }) {
1054
+ async function forward({ upstream, request, body, timeoutMs = null, metrics = null, forwardPolicy = {} }) {
920
1055
  const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
921
1056
  try {
922
1057
  return await fetch(target, {
923
1058
  method: request.method,
924
- headers: filteredHeaders(request.headers),
1059
+ headers: filteredHeaders(request.headers, forwardPolicy),
925
1060
  body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
926
1061
  signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
927
1062
  });
@@ -949,24 +1084,117 @@ function buildUpstreamUrl({ upstream, requestUrl }) {
949
1084
  return new URL(`${parsed.pathname}${parsed.search}`, upstream.endsWith("/") ? upstream : `${upstream}/`);
950
1085
  }
951
1086
 
952
- function filteredHeaders(headers) {
1087
+ // P0-CR-001 — DEFAULT-DROP upstream header allowlist. The client's request
1088
+ // headers cross from the local gateway trust boundary into the MODEL PROVIDER
1089
+ // boundary, so the policy is: forward ONLY a known-safe set; everything else
1090
+ // (including ambient client credentials — Cookie, Proxy-Authorization, and the
1091
+ // client's gateway Authorization) is dropped. The conditional `authorization`
1092
+ // rule is handled in filteredHeaders against the forward policy. An operator can
1093
+ // additively widen the set with `target.forwardHeaders` for an unusual upstream.
1094
+ //
1095
+ // The forwarded set is exactly the headers the OpenAI-compatible / Anthropic /
1096
+ // Gemini adapters need: the provider key headers (x-api-key, x-goog-api-key,
1097
+ // openai-organization, openai-beta), provider version/feature pins
1098
+ // (anthropic-version, anthropic-beta), and benign request metadata (accept,
1099
+ // content-type — always rewritten to application/json, user-agent,
1100
+ // accept-language). content-type is set unconditionally below so it is NOT in
1101
+ // this set.
1102
+ const FORWARD_HEADER_ALLOWLIST = new Set([
1103
+ "x-api-key",
1104
+ "anthropic-version",
1105
+ "anthropic-beta",
1106
+ "x-goog-api-key",
1107
+ "openai-organization",
1108
+ "openai-beta",
1109
+ "accept",
1110
+ "user-agent",
1111
+ "accept-language"
1112
+ ]);
1113
+
1114
+ // ALWAYS-DROP: ambient client credentials + hop-by-hop control headers. These
1115
+ // must NEVER reach the upstream regardless of the allowlist or the operator's
1116
+ // target.forwardHeaders extension (a fail-closed denylist that wins over both).
1117
+ // - host / content-length: rewritten/recomputed by fetch for the new request.
1118
+ // - cookie / set-cookie / proxy-authorization: ambient client credentials.
1119
+ // - connection / keep-alive / te / trailer / transfer-encoding / upgrade:
1120
+ // hop-by-hop headers (RFC 7230 §6.1) that must not be tunneled end-to-end.
1121
+ const FORWARD_HEADER_DENYLIST = new Set([
1122
+ "host",
1123
+ "content-length",
1124
+ "cookie",
1125
+ "set-cookie",
1126
+ "proxy-authorization",
1127
+ "connection",
1128
+ "keep-alive",
1129
+ "te",
1130
+ "trailer",
1131
+ "transfer-encoding",
1132
+ "upgrade"
1133
+ ]);
1134
+
1135
+ // `forwardPolicy` is built by createHaechiProxy from the runtime: it carries
1136
+ // - gatewayConsumedAuthorization: true when auth.provider !== "none", i.e. the
1137
+ // gateway authenticated the CLIENT with the request's Authorization. That
1138
+ // header is the GATEWAY credential Haechi already consumed; forwarding it
1139
+ // would leak a gateway secret into the model provider, so it is DROPPED.
1140
+ // When false (auth.provider "none"), the client's Authorization is the
1141
+ // UPSTREAM provider key (the OpenAI-compatible pass-through pattern), so it
1142
+ // is FORWARDED.
1143
+ // - extraHeaders: the operator's additive target.forwardHeaders allowlist
1144
+ // (lowercase names) — never able to override the always-drop denylist.
1145
+ function filteredHeaders(headers, forwardPolicy = {}) {
1146
+ const gatewayConsumedAuthorization = Boolean(forwardPolicy.gatewayConsumedAuthorization);
1147
+ const extraHeaders = forwardPolicy.extraHeaders instanceof Set
1148
+ ? forwardPolicy.extraHeaders
1149
+ : new Set(Array.isArray(forwardPolicy.extraHeaders) ? forwardPolicy.extraHeaders : []);
1150
+
953
1151
  const next = new Headers();
954
1152
  for (const [key, value] of Object.entries(headers)) {
955
- if (!value || ["host", "content-length"].includes(key.toLowerCase())) {
1153
+ if (!value) {
956
1154
  continue;
957
1155
  }
958
- if (Array.isArray(value)) {
959
- for (const item of value) {
960
- next.append(key, item);
1156
+ const name = key.toLowerCase();
1157
+
1158
+ // Always-drop wins over everything (credentials + hop-by-hop).
1159
+ if (FORWARD_HEADER_DENYLIST.has(name)) {
1160
+ continue;
1161
+ }
1162
+
1163
+ // Conditional gateway-vs-upstream Authorization separation.
1164
+ if (name === "authorization") {
1165
+ if (gatewayConsumedAuthorization) {
1166
+ // Gateway token Haechi already consumed — must not leak upstream.
1167
+ continue;
961
1168
  }
962
- } else {
963
- next.set(key, value);
1169
+ // auth.provider "none": the client put the UPSTREAM provider key here.
1170
+ appendHeader(next, key, value);
1171
+ continue;
964
1172
  }
1173
+
1174
+ // content-type is rewritten unconditionally below; skip the client's value.
1175
+ if (name === "content-type") {
1176
+ continue;
1177
+ }
1178
+
1179
+ if (FORWARD_HEADER_ALLOWLIST.has(name) || extraHeaders.has(name)) {
1180
+ appendHeader(next, key, value);
1181
+ }
1182
+ // Everything else is default-dropped (fail-closed).
965
1183
  }
966
1184
  next.set("content-type", "application/json");
967
1185
  return next;
968
1186
  }
969
1187
 
1188
+ function appendHeader(target, key, value) {
1189
+ if (Array.isArray(value)) {
1190
+ for (const item of value) {
1191
+ target.append(key, item);
1192
+ }
1193
+ } else {
1194
+ target.set(key, value);
1195
+ }
1196
+ }
1197
+
970
1198
  function readBody(request, { maxBytes }) {
971
1199
  return new Promise((resolve, reject) => {
972
1200
  const chunks = [];
@@ -1042,9 +1270,13 @@ function isJson(contentType = "") {
1042
1270
  }
1043
1271
 
1044
1272
  function transformedJsonHeaders(headers) {
1273
+ // P1-CR-003 — defensively strip the full hop-by-hop/compression set (the
1274
+ // caller already passes the sanitized headers, but the transformed JSON body
1275
+ // is freshly serialized, so any stale length/encoding metadata must not leak).
1045
1276
  const next = { ...headers, "content-type": "application/json" };
1046
- delete next["content-length"];
1047
- delete next["content-encoding"];
1277
+ for (const name of RESPONSE_HOP_BY_HOP_HEADERS) {
1278
+ delete next[name];
1279
+ }
1048
1280
  return next;
1049
1281
  }
1050
1282
 
@@ -1077,10 +1309,13 @@ async function unprotectedResponseDecision({
1077
1309
  metrics?.increment("haechi_response_unprotected_total");
1078
1310
 
1079
1311
  if (allowed) {
1312
+ // P1-CR-003 — `headers` is already the sanitized set (no stale
1313
+ // compression/length metadata). Re-set a correct content-length for this
1314
+ // fully-buffered body.
1080
1315
  return {
1081
1316
  decision,
1082
1317
  status: upstreamResponse.status,
1083
- headers,
1318
+ headers: { ...headers, "content-length": String(rawBody.byteLength) },
1084
1319
  body: rawBody
1085
1320
  };
1086
1321
  }
@@ -18,6 +18,61 @@ import { lookup as dnsLookup } from "node:dns/promises";
18
18
  const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
19
19
  const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
20
20
 
21
+ // Parse an IPv6 literal into its 16 octets (or null when it is not a valid IPv6
22
+ // text form). This is the SOUND way to recognise an IPv4-mapped IPv6 address in
23
+ // EVERY textual form: dotted (::ffff:127.0.0.1), HEX (::ffff:7f00:1), bracketed
24
+ // ([::ffff:7f00:1], stripped by the caller), leading-zero (::ffff:7f00:0001),
25
+ // mixed `::` compression, and case-insensitive ffff. We classify the last 32
26
+ // bits as the embedded IPv4 ONLY when bytes 0..9 are zero and bytes 10..11 are
27
+ // 0xffff (the ::ffff:0:0/96 IPv4-mapped prefix), so a genuinely public mapped
28
+ // address (::ffff:8.8.8.8 == ::ffff:808:808) stays allowed and a non-mapped v6
29
+ // (::ffff:0:7f00:1, NAT64 64:ff9b::…) is NOT mistaken for an embedded IPv4.
30
+ function ipv6ToBytes(str) {
31
+ let s = str;
32
+ // A trailing dotted IPv4 quad (::ffff:127.0.0.1) — peel it off into the final
33
+ // two hextets so the remaining text is pure hex groups.
34
+ let tailV4 = null;
35
+ if (s.includes(".")) {
36
+ const idx = s.lastIndexOf(":");
37
+ if (idx === -1) return null;
38
+ const quad = s.slice(idx + 1).split(".");
39
+ if (quad.length !== 4) return null;
40
+ const oct = quad.map(Number);
41
+ if (oct.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
42
+ tailV4 = oct;
43
+ s = `${s.slice(0, idx + 1)}0:0`; // placeholder hextets; overwritten below
44
+ }
45
+ const halves = s.split("::");
46
+ if (halves.length > 2) return null; // at most one "::"
47
+ const toGroups = (g) => (g === "" ? [] : g.split(":").map((h) => (/^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : NaN)));
48
+ const head = toGroups(halves[0]);
49
+ const tail = halves.length === 2 ? toGroups(halves[1]) : null;
50
+ if (head.some(Number.isNaN) || (tail && tail.some(Number.isNaN))) return null;
51
+ let groups;
52
+ if (tail === null) {
53
+ if (head.length !== 8) return null;
54
+ groups = head;
55
+ } else {
56
+ const missing = 8 - head.length - tail.length;
57
+ if (missing < 0) return null;
58
+ groups = [...head, ...Array(missing).fill(0), ...tail];
59
+ }
60
+ if (groups.length !== 8) return null;
61
+ const bytes = [];
62
+ for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
63
+ if (tailV4) { bytes[12] = tailV4[0]; bytes[13] = tailV4[1]; bytes[14] = tailV4[2]; bytes[15] = tailV4[3]; }
64
+ return bytes;
65
+ }
66
+
67
+ // Return the embedded IPv4 dotted quad of an IPv4-mapped IPv6 address, or null.
68
+ function mappedIpv4(bare) {
69
+ const b = ipv6ToBytes(bare);
70
+ if (!b) return null;
71
+ for (let i = 0; i < 10; i += 1) if (b[i] !== 0) return null; // bytes 0..9 must be zero
72
+ if (b[10] !== 0xff || b[11] !== 0xff) return null; // bytes 10..11 must be 0xffff
73
+ return `${b[12]}.${b[13]}.${b[14]}.${b[15]}`;
74
+ }
75
+
21
76
  // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
22
77
  // Applied to both a literal host in the URL and every DNS-resolved address. This
23
78
  // is the canonical copy; the satellite copies must agree (parity-tested).
@@ -39,10 +94,11 @@ export function isBlockedAddress(host) {
39
94
  if (v === 6) {
40
95
  const h = bare.toLowerCase();
41
96
  if (h === "::1" || h === "::") return true; // loopback / unspecified
42
- if (h.startsWith("::ffff:")) { // IPv4-mapped
43
- const mapped = h.slice("::ffff:".length);
44
- if (isIP(mapped) === 4) return isBlockedAddress(mapped);
45
- }
97
+ // IPv4-mapped IPv6 — normalise to the embedded IPv4 (handles dotted AND hex
98
+ // forms, e.g. ::ffff:127.0.0.1 and ::ffff:7f00:1) and run the v4 check, so a
99
+ // private/loopback/metadata target can't slip past as hex (P1-CR-002).
100
+ const mapped = mappedIpv4(bare);
101
+ if (mapped !== null) return isBlockedAddress(mapped);
46
102
  // Range-check the first hextet: fe80::/10 link-local, fc00::/7 ULA, ff00::/8 multicast.
47
103
  const firstHextet = parseInt(h.split(":")[0] || "", 16);
48
104
  if (Number.isFinite(firstHextet)) {