haechi 1.3.0 → 1.3.2

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
@@ -213,7 +227,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
213
227
  const authContext = { identity, profile, policyEngine, correlationId };
214
228
 
215
229
  const body = await readBody(request, {
216
- maxBytes: config.limits.maxRequestBytes
230
+ maxBytes: config.limits.maxRequestBytes,
231
+ response
217
232
  });
218
233
  const json = parseJsonBody(body);
219
234
 
@@ -237,7 +252,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
237
252
 
238
253
  if (isStreamingRequest(json, routeContext)) {
239
254
  if (config.streaming.requestMode === "inspect") {
240
- await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics });
255
+ await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
241
256
  return;
242
257
  }
243
258
 
@@ -254,16 +269,36 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
254
269
  blocked: false
255
270
  });
256
271
  countDecision(metrics, { routeContext, mode, decision: "forwarded" });
272
+ // CR2-001 — a per-request AbortController whose signal is threaded into
273
+ // the upstream fetch; aborting it (on a downstream client disconnect)
274
+ // tears down the upstream request + body so neither leaks.
275
+ const streamAbort = new AbortController();
257
276
  const upstreamResponse = await forward({
258
277
  upstream: config.target.upstream,
259
278
  request,
260
279
  body,
261
280
  timeoutMs: config.limits.upstreamTimeoutMs,
262
- metrics
281
+ metrics,
282
+ forwardPolicy,
283
+ abortController: streamAbort
284
+ });
285
+ // P1-CR-003 — sanitize response headers (strip the upstream's
286
+ // content-encoding/content-length/transfer/hop-by-hop) on this path
287
+ // too: Node fetch() auto-decompressed the body, so the original
288
+ // compressed headers would now be wrong. P1-CR-004 — TRUE bounded
289
+ // streaming pass-through: pipe the upstream body to the client with a
290
+ // running byte cap instead of buffering the whole response.
291
+ response.writeHead(upstreamResponse.status, sanitizeResponseHeaders(upstreamResponse));
292
+ await pipeUpstreamBodyBounded({
293
+ upstreamResponse,
294
+ response,
295
+ request,
296
+ maxBytes: streamingPassThroughMaxBytes(config),
297
+ abortController: streamAbort,
298
+ logger,
299
+ metrics,
300
+ correlationId
263
301
  });
264
- const { body: rawBody } = await readUpstreamBody(upstreamResponse);
265
- response.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers.entries()));
266
- response.end(rawBody);
267
302
  return;
268
303
  }
269
304
 
@@ -301,7 +336,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
301
336
  request,
302
337
  body: JSON.stringify(result.payload),
303
338
  timeoutMs: config.limits.upstreamTimeoutMs,
304
- metrics
339
+ metrics,
340
+ forwardPolicy
305
341
  });
306
342
 
307
343
  const forwarded = await maybeProtectResponse({
@@ -332,10 +368,16 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
332
368
  });
333
369
  metrics.increment("haechi_internal_error_total");
334
370
  }
371
+ // CR2-005 — an over-limit request body teardown carries `Connection: close`
372
+ // so the socket releases once the 413 is delivered (readBody destroys the
373
+ // request on response finish/close).
374
+ const extraHeaders = error?.errorCode === "haechi_request_body_too_large"
375
+ ? { connection: "close" }
376
+ : null;
335
377
  writeJson(response, error.statusCode ?? 500, {
336
378
  error: error.errorCode ?? "haechi_proxy_error",
337
379
  message: expected ? error.message : "Internal proxy error"
338
- });
380
+ }, extraHeaders);
339
381
  } finally {
340
382
  const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9;
341
383
  // route label is a bounded route id (or "unknown") — never an identity/value.
@@ -633,7 +675,7 @@ async function recordAuthDenied({ runtime, routeContext, reason, correlationId =
633
675
  });
634
676
  }
635
677
 
636
- async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {}, metrics = null }) {
678
+ async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {}, metrics = null, forwardPolicy = {} }) {
637
679
  const { haechi, config } = runtime;
638
680
  const requestMode = config.policy.mode ?? config.mode;
639
681
 
@@ -674,7 +716,8 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
674
716
  request,
675
717
  body: JSON.stringify(requestResult.payload),
676
718
  timeoutMs: config.limits.upstreamTimeoutMs,
677
- metrics
719
+ metrics,
720
+ forwardPolicy
678
721
  });
679
722
 
680
723
  const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
@@ -687,7 +730,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
687
730
  maxMatchBytes: config.streaming.maxMatchBytes
688
731
  });
689
732
 
690
- response.writeHead(upstreamResponse.status, streamingResponseHeaders(upstreamResponse));
733
+ response.writeHead(upstreamResponse.status, sanitizeResponseHeaders(upstreamResponse));
691
734
 
692
735
  const { blocked, summary } = await inspectResponseStream({
693
736
  source: upstreamResponse.body ?? emptyAsyncIterable(),
@@ -708,13 +751,151 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
708
751
  response.end();
709
752
  }
710
753
 
711
- function streamingResponseHeaders(upstreamResponse) {
754
+ // P1-CR-003 — the SINGLE centralized response-header sanitizer used on EVERY
755
+ // response path (pass-through, forwarded/unprotected, protected, streaming).
756
+ // Node fetch() auto-decompresses gzip/br/deflate, so the upstream's original
757
+ // content-encoding/content-length now describe the WIRE bytes Haechi no longer
758
+ // emits — forwarding them makes a downstream client see "content-encoding: gzip"
759
+ // on plain bytes and fail with "incorrect header check". transfer-encoding and
760
+ // the hop-by-hop control headers (RFC 7230 §6.1) likewise describe the upstream
761
+ // hop, not Haechi's connection to the client, so they are stripped too. A
762
+ // correct content-length is re-set ONLY by a caller that emits a fully-buffered
763
+ // body (transformedJsonHeaders / the buffered-body helper below); a streamed or
764
+ // raw-piped body intentionally carries no content-length.
765
+ const RESPONSE_HOP_BY_HOP_HEADERS = [
766
+ "content-encoding",
767
+ "content-length",
768
+ "transfer-encoding",
769
+ "connection",
770
+ "keep-alive",
771
+ "te",
772
+ "trailer",
773
+ "upgrade",
774
+ "proxy-authenticate"
775
+ ];
776
+
777
+ function sanitizeResponseHeaders(upstreamResponse) {
712
778
  const headers = Object.fromEntries(upstreamResponse.headers.entries());
713
- delete headers["content-length"];
714
- delete headers["content-encoding"];
779
+ for (const name of RESPONSE_HOP_BY_HOP_HEADERS) {
780
+ delete headers[name];
781
+ }
715
782
  return headers;
716
783
  }
717
784
 
785
+ // P1-CR-004 — the byte cap for the streaming pass-through path. Reuse
786
+ // responseProtection.maxBytes (the existing hard response-size cap) so a single
787
+ // dial governs all raw upstream-body reads; falls back to a 1 MiB default for a
788
+ // hand-built config without responseProtection.
789
+ function streamingPassThroughMaxBytes(config) {
790
+ const cap = config.responseProtection?.maxBytes;
791
+ return typeof cap === "number" && cap > 0 ? cap : 1048576;
792
+ }
793
+
794
+ // P1-CR-004 — TRUE bounded streaming pass-through. Pipe the upstream body to the
795
+ // client response as it arrives (real streaming) while counting bytes; if the
796
+ // running total exceeds maxBytes, abort: cancel the upstream reader and destroy
797
+ // the client response so a long-lived or malicious stream cannot hold memory or
798
+ // the connection open unbounded. Bytes already written cannot be retracted, so
799
+ // this caps total memory/throughput, not the already-flushed prefix.
800
+ async function pipeUpstreamBodyBounded({ upstreamResponse, response, request = null, maxBytes, abortController = null, logger = null, metrics = null, correlationId = null }) {
801
+ if (!upstreamResponse.body) {
802
+ response.end();
803
+ return;
804
+ }
805
+
806
+ const reader = upstreamResponse.body.getReader();
807
+ let received = 0;
808
+
809
+ // CR2-001 — a ONE-SHOT teardown on a downstream client disconnect. Without it,
810
+ // a parked `await once(response, "drain")` (backpressure) or a parked
811
+ // `await reader.read()` (no backpressure, upstream idle) never unparks after the
812
+ // client socket dies — neither `drain` nor `error` fires — so the async task and
813
+ // the upstream connection leak. On `close`/`aborted` we cancel the upstream
814
+ // reader (interrupts a parked read) AND abort the upstream fetch (tears down the
815
+ // connection); the listeners are removed on normal completion so the happy path
816
+ // does not leak a handle.
817
+ let disconnected = false;
818
+ // A SINGLE promise resolved by the one-shot tearDown below, so the backpressure
819
+ // wait can race against the disconnect WITHOUT registering a fresh `close`
820
+ // listener every drain cycle (which would accumulate on a sustained
821
+ // backpressured stream and trip MaxListenersExceededWarning).
822
+ let signalDisconnected;
823
+ const disconnectedPromise = new Promise((resolve) => {
824
+ signalDisconnected = resolve;
825
+ });
826
+ const tearDown = () => {
827
+ if (disconnected) {
828
+ return;
829
+ }
830
+ disconnected = true;
831
+ signalDisconnected();
832
+ void cancelReader(reader);
833
+ abortController?.abort();
834
+ };
835
+ const disconnectSources = [response, request].filter(Boolean);
836
+ for (const source of disconnectSources) {
837
+ source.once("close", tearDown);
838
+ source.once("aborted", tearDown);
839
+ }
840
+ const cleanupListeners = () => {
841
+ for (const source of disconnectSources) {
842
+ source.removeListener("close", tearDown);
843
+ source.removeListener("aborted", tearDown);
844
+ }
845
+ };
846
+
847
+ try {
848
+ while (true) {
849
+ if (disconnected) {
850
+ return;
851
+ }
852
+ const { done, value } = await reader.read();
853
+ if (done) {
854
+ break;
855
+ }
856
+ received += value.byteLength;
857
+ if (maxBytes && received > maxBytes) {
858
+ // Over the cap: stop reading upstream and tear down the client write so
859
+ // the oversize stream is bounded (fail-closed on size).
860
+ void cancelReader(reader);
861
+ abortController?.abort();
862
+ metrics?.increment("haechi_response_stream_truncated_total");
863
+ logger?.error("proxy_stream_pass_through_too_large", {
864
+ correlationId,
865
+ maxBytes
866
+ });
867
+ if (!response.writableEnded) {
868
+ response.destroy();
869
+ }
870
+ return;
871
+ }
872
+ // Respect downstream backpressure: stop pulling upstream until the client
873
+ // socket has drained. CR2-001 — race the drain wait against `close` so a
874
+ // client disconnect mid-backpressure unparks the wait instead of hanging
875
+ // until the request timeout.
876
+ const ok = response.write(Buffer.from(value));
877
+ if (!ok && !disconnected) {
878
+ await Promise.race([
879
+ once(response, "drain"),
880
+ disconnectedPromise
881
+ ]);
882
+ if (disconnected || response.writableEnded || response.destroyed) {
883
+ return;
884
+ }
885
+ }
886
+ }
887
+ response.end();
888
+ } catch (error) {
889
+ void cancelReader(reader);
890
+ abortController?.abort();
891
+ if (!response.writableEnded) {
892
+ response.destroy();
893
+ }
894
+ } finally {
895
+ cleanupListeners();
896
+ }
897
+ }
898
+
718
899
  function nodeResponseSink(response) {
719
900
  return {
720
901
  write(text) {
@@ -751,20 +932,42 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
751
932
  }
752
933
 
753
934
  async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [], metrics = null }) {
754
- const headers = Object.fromEntries(upstreamResponse.headers.entries());
935
+ // P1-CR-003 content-encoding is read off the RAW upstream headers (before
936
+ // sanitation) for the compressed-response gate; the headers RETURNED to the
937
+ // client are always the sanitized set (no stale compression/length metadata).
938
+ const rawHeaders = Object.fromEntries(upstreamResponse.headers.entries());
939
+ const headers = sanitizeResponseHeaders(upstreamResponse);
755
940
 
756
941
  if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
757
- const { body: rawBody } = await readUpstreamBody(upstreamResponse);
942
+ // P1-CR-004 apply the same byte cap to this raw upstream-body read so an
943
+ // unprotected/forwarded response cannot be buffered unbounded. Fail closed
944
+ // (502) when the upstream body exceeds the cap.
945
+ const passThroughMax = streamingPassThroughMaxBytes(runtime.config);
946
+ const { body: rawBody, tooLarge } = await readUpstreamBody(upstreamResponse, { maxBytes: passThroughMax });
947
+ if (tooLarge) {
948
+ metrics?.increment("haechi_response_stream_truncated_total");
949
+ return {
950
+ decision: "response_unprotected_blocked",
951
+ status: 502,
952
+ headers: { "content-type": "application/json" },
953
+ body: Buffer.from(`${JSON.stringify({
954
+ error: "haechi_response_too_large",
955
+ reason: "response_body_too_large",
956
+ message: `Response body exceeds responseProtection.maxBytes (${passThroughMax})`
957
+ }, null, 2)}\n`)
958
+ };
959
+ }
758
960
  return {
759
961
  status: upstreamResponse.status,
760
- headers,
962
+ // Re-set a correct content-length: this is a fully-buffered body.
963
+ headers: { ...headers, "content-length": String(rawBody.byteLength) },
761
964
  body: rawBody,
762
965
  decision: "forwarded"
763
966
  };
764
967
  }
765
968
 
766
969
  const responsePolicy = runtime.config.responseProtection;
767
- const contentEncoding = headers["content-encoding"] ?? "";
970
+ const contentEncoding = rawHeaders["content-encoding"] ?? "";
768
971
  const bodyRead = await readUpstreamBody(upstreamResponse, { maxBytes: responsePolicy.maxBytes });
769
972
 
770
973
  if (bodyRead.tooLarge) {
@@ -916,14 +1119,24 @@ function restoreTokens(value, tokenValues) {
916
1119
  return value;
917
1120
  }
918
1121
 
919
- async function forward({ upstream, request, body, timeoutMs = null, metrics = null }) {
1122
+ async function forward({ upstream, request, body, timeoutMs = null, metrics = null, forwardPolicy = {}, abortController = null }) {
920
1123
  const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
1124
+ // CR2-001 — combine the upstream timeout with a per-request AbortController so a
1125
+ // downstream client disconnect (which aborts `abortController`) tears down the
1126
+ // in-flight upstream fetch + its body, instead of leaking the connection.
1127
+ const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : null;
1128
+ let signal;
1129
+ if (abortController && timeoutSignal) {
1130
+ signal = AbortSignal.any([abortController.signal, timeoutSignal]);
1131
+ } else {
1132
+ signal = abortController ? abortController.signal : timeoutSignal ?? undefined;
1133
+ }
921
1134
  try {
922
1135
  return await fetch(target, {
923
1136
  method: request.method,
924
- headers: filteredHeaders(request.headers),
1137
+ headers: filteredHeaders(request.headers, forwardPolicy),
925
1138
  body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
926
- signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
1139
+ signal
927
1140
  });
928
1141
  } catch (error) {
929
1142
  if (error?.name === "TimeoutError" || error?.name === "AbortError") {
@@ -949,25 +1162,118 @@ function buildUpstreamUrl({ upstream, requestUrl }) {
949
1162
  return new URL(`${parsed.pathname}${parsed.search}`, upstream.endsWith("/") ? upstream : `${upstream}/`);
950
1163
  }
951
1164
 
952
- function filteredHeaders(headers) {
1165
+ // P0-CR-001 — DEFAULT-DROP upstream header allowlist. The client's request
1166
+ // headers cross from the local gateway trust boundary into the MODEL PROVIDER
1167
+ // boundary, so the policy is: forward ONLY a known-safe set; everything else
1168
+ // (including ambient client credentials — Cookie, Proxy-Authorization, and the
1169
+ // client's gateway Authorization) is dropped. The conditional `authorization`
1170
+ // rule is handled in filteredHeaders against the forward policy. An operator can
1171
+ // additively widen the set with `target.forwardHeaders` for an unusual upstream.
1172
+ //
1173
+ // The forwarded set is exactly the headers the OpenAI-compatible / Anthropic /
1174
+ // Gemini adapters need: the provider key headers (x-api-key, x-goog-api-key,
1175
+ // openai-organization, openai-beta), provider version/feature pins
1176
+ // (anthropic-version, anthropic-beta), and benign request metadata (accept,
1177
+ // content-type — always rewritten to application/json, user-agent,
1178
+ // accept-language). content-type is set unconditionally below so it is NOT in
1179
+ // this set.
1180
+ const FORWARD_HEADER_ALLOWLIST = new Set([
1181
+ "x-api-key",
1182
+ "anthropic-version",
1183
+ "anthropic-beta",
1184
+ "x-goog-api-key",
1185
+ "openai-organization",
1186
+ "openai-beta",
1187
+ "accept",
1188
+ "user-agent",
1189
+ "accept-language"
1190
+ ]);
1191
+
1192
+ // ALWAYS-DROP: ambient client credentials + hop-by-hop control headers. These
1193
+ // must NEVER reach the upstream regardless of the allowlist or the operator's
1194
+ // target.forwardHeaders extension (a fail-closed denylist that wins over both).
1195
+ // - host / content-length: rewritten/recomputed by fetch for the new request.
1196
+ // - cookie / set-cookie / proxy-authorization: ambient client credentials.
1197
+ // - connection / keep-alive / te / trailer / transfer-encoding / upgrade:
1198
+ // hop-by-hop headers (RFC 7230 §6.1) that must not be tunneled end-to-end.
1199
+ const FORWARD_HEADER_DENYLIST = new Set([
1200
+ "host",
1201
+ "content-length",
1202
+ "cookie",
1203
+ "set-cookie",
1204
+ "proxy-authorization",
1205
+ "connection",
1206
+ "keep-alive",
1207
+ "te",
1208
+ "trailer",
1209
+ "transfer-encoding",
1210
+ "upgrade"
1211
+ ]);
1212
+
1213
+ // `forwardPolicy` is built by createHaechiProxy from the runtime: it carries
1214
+ // - gatewayConsumedAuthorization: true when auth.provider !== "none", i.e. the
1215
+ // gateway authenticated the CLIENT with the request's Authorization. That
1216
+ // header is the GATEWAY credential Haechi already consumed; forwarding it
1217
+ // would leak a gateway secret into the model provider, so it is DROPPED.
1218
+ // When false (auth.provider "none"), the client's Authorization is the
1219
+ // UPSTREAM provider key (the OpenAI-compatible pass-through pattern), so it
1220
+ // is FORWARDED.
1221
+ // - extraHeaders: the operator's additive target.forwardHeaders allowlist
1222
+ // (lowercase names) — never able to override the always-drop denylist.
1223
+ function filteredHeaders(headers, forwardPolicy = {}) {
1224
+ const gatewayConsumedAuthorization = Boolean(forwardPolicy.gatewayConsumedAuthorization);
1225
+ const extraHeaders = forwardPolicy.extraHeaders instanceof Set
1226
+ ? forwardPolicy.extraHeaders
1227
+ : new Set(Array.isArray(forwardPolicy.extraHeaders) ? forwardPolicy.extraHeaders : []);
1228
+
953
1229
  const next = new Headers();
954
1230
  for (const [key, value] of Object.entries(headers)) {
955
- if (!value || ["host", "content-length"].includes(key.toLowerCase())) {
1231
+ if (!value) {
956
1232
  continue;
957
1233
  }
958
- if (Array.isArray(value)) {
959
- for (const item of value) {
960
- next.append(key, item);
1234
+ const name = key.toLowerCase();
1235
+
1236
+ // Always-drop wins over everything (credentials + hop-by-hop).
1237
+ if (FORWARD_HEADER_DENYLIST.has(name)) {
1238
+ continue;
1239
+ }
1240
+
1241
+ // Conditional gateway-vs-upstream Authorization separation.
1242
+ if (name === "authorization") {
1243
+ if (gatewayConsumedAuthorization) {
1244
+ // Gateway token Haechi already consumed — must not leak upstream.
1245
+ continue;
961
1246
  }
962
- } else {
963
- next.set(key, value);
1247
+ // auth.provider "none": the client put the UPSTREAM provider key here.
1248
+ appendHeader(next, key, value);
1249
+ continue;
964
1250
  }
1251
+
1252
+ // content-type is rewritten unconditionally below; skip the client's value.
1253
+ if (name === "content-type") {
1254
+ continue;
1255
+ }
1256
+
1257
+ if (FORWARD_HEADER_ALLOWLIST.has(name) || extraHeaders.has(name)) {
1258
+ appendHeader(next, key, value);
1259
+ }
1260
+ // Everything else is default-dropped (fail-closed).
965
1261
  }
966
1262
  next.set("content-type", "application/json");
967
1263
  return next;
968
1264
  }
969
1265
 
970
- function readBody(request, { maxBytes }) {
1266
+ function appendHeader(target, key, value) {
1267
+ if (Array.isArray(value)) {
1268
+ for (const item of value) {
1269
+ target.append(key, item);
1270
+ }
1271
+ } else {
1272
+ target.set(key, value);
1273
+ }
1274
+ }
1275
+
1276
+ function readBody(request, { maxBytes, response = null }) {
971
1277
  return new Promise((resolve, reject) => {
972
1278
  const chunks = [];
973
1279
  let received = 0;
@@ -980,6 +1286,26 @@ function readBody(request, { maxBytes }) {
980
1286
  received += chunk.byteLength;
981
1287
  if (received > maxBytes) {
982
1288
  rejected = true;
1289
+ // CR2-005 — stop reading and release the socket PROMPTLY instead of
1290
+ // reading-and-discarding the rest of the upload until Node's finite
1291
+ // requestTimeout. pause() halts the flowing read immediately (no further
1292
+ // data is consumed); the connection is then torn down — but only AFTER the
1293
+ // 413 has been written, so the client still receives it. The 413 carries
1294
+ // `Connection: close` and the socket is destroyed once the response
1295
+ // finishes/closes (destroying before the response is sent would reset the
1296
+ // socket and the client would get a transport error instead of the 413).
1297
+ request.pause();
1298
+ if (response) {
1299
+ const destroyRequest = () => {
1300
+ if (!request.destroyed) {
1301
+ request.destroy();
1302
+ }
1303
+ };
1304
+ response.once("finish", destroyRequest);
1305
+ response.once("close", destroyRequest);
1306
+ } else {
1307
+ request.destroy();
1308
+ }
983
1309
  reject(proxyError({
984
1310
  statusCode: 413,
985
1311
  errorCode: "haechi_request_body_too_large",
@@ -1032,8 +1358,8 @@ function parseJsonBody(body) {
1032
1358
  }
1033
1359
  }
1034
1360
 
1035
- function writeJson(response, status, body) {
1036
- response.writeHead(status, { "content-type": "application/json" });
1361
+ function writeJson(response, status, body, extraHeaders = null) {
1362
+ response.writeHead(status, { "content-type": "application/json", ...(extraHeaders ?? {}) });
1037
1363
  response.end(`${JSON.stringify(body, null, 2)}\n`);
1038
1364
  }
1039
1365
 
@@ -1041,10 +1367,32 @@ function isJson(contentType = "") {
1041
1367
  return contentType.toLowerCase().includes("application/json");
1042
1368
  }
1043
1369
 
1370
+ // CR2-004 — body-coupled validator headers that describe the UPSTREAM body. On a
1371
+ // transformed (protected/redacted/re-serialized) response the body changed, so
1372
+ // these become stale and must be dropped (a client/proxy honoring the upstream's
1373
+ // etag/last-modified could otherwise serve or revalidate against the wrong body).
1374
+ const BODY_COUPLED_VALIDATOR_HEADERS = [
1375
+ "etag",
1376
+ "content-md5",
1377
+ "digest",
1378
+ "last-modified"
1379
+ ];
1380
+
1044
1381
  function transformedJsonHeaders(headers) {
1382
+ // P1-CR-003 — defensively strip the full hop-by-hop/compression set (the
1383
+ // caller already passes the sanitized headers, but the transformed JSON body
1384
+ // is freshly serialized, so any stale length/encoding metadata must not leak).
1045
1385
  const next = { ...headers, "content-type": "application/json" };
1046
- delete next["content-length"];
1047
- delete next["content-encoding"];
1386
+ for (const name of RESPONSE_HOP_BY_HOP_HEADERS) {
1387
+ delete next[name];
1388
+ }
1389
+ // CR2-004 — the body was MUTATED, so drop validators coupled to the upstream
1390
+ // body and forbid caching the rewritten response. This path only (the raw
1391
+ // pass-through path keeps its etag — its body is byte-unchanged so still valid).
1392
+ for (const name of BODY_COUPLED_VALIDATOR_HEADERS) {
1393
+ delete next[name];
1394
+ }
1395
+ next["cache-control"] = "no-store";
1048
1396
  return next;
1049
1397
  }
1050
1398
 
@@ -1077,10 +1425,13 @@ async function unprotectedResponseDecision({
1077
1425
  metrics?.increment("haechi_response_unprotected_total");
1078
1426
 
1079
1427
  if (allowed) {
1428
+ // P1-CR-003 — `headers` is already the sanitized set (no stale
1429
+ // compression/length metadata). Re-set a correct content-length for this
1430
+ // fully-buffered body.
1080
1431
  return {
1081
1432
  decision,
1082
1433
  status: upstreamResponse.status,
1083
- headers,
1434
+ headers: { ...headers, "content-length": String(rawBody.byteLength) },
1084
1435
  body: rawBody
1085
1436
  };
1086
1437
  }
@@ -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)) {