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.
- package/README.ko.md +15 -4
- package/README.md +15 -4
- package/docs/current/code-review-risk-register-2026-06-16-round2.ko.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16-round2.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16.ko.md +377 -0
- package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
- package/docs/current/configuration.ko.md +2 -1
- package/docs/current/configuration.md +2 -1
- package/docs/current/operations-runbook.ko.md +21 -1
- package/docs/current/operations-runbook.md +22 -1
- package/docs/current/release-process.ko.md +14 -6
- package/docs/current/release-process.md +14 -6
- package/docs/current/risk-register-release-gate.ko.md +48 -5
- package/docs/current/risk-register-release-gate.md +48 -5
- package/docs/current/shared-responsibility.ko.md +10 -1
- package/docs/current/shared-responsibility.md +10 -1
- package/docs/current/threat-model.ko.md +3 -0
- package/docs/current/threat-model.md +3 -0
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +92 -3
- package/packages/cli/runtime.mjs +54 -1
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/plugin/process-sandbox.mjs +56 -1
- package/packages/plugin/sandbox.mjs +23 -0
- package/packages/proxy/index.mjs +385 -34
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +127 -12
- package/packages/token-vault/index.mjs +46 -5
package/packages/proxy/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
1231
|
+
if (!value) {
|
|
956
1232
|
continue;
|
|
957
1233
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
next
|
|
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
|
|
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
|
-
|
|
1047
|
-
|
|
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
|
}
|
package/packages/ssrf/index.mjs
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)) {
|