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.
- package/README.ko.md +12 -1
- package/README.md +12 -1
- 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/risk-register-release-gate.ko.md +30 -5
- package/docs/current/risk-register-release-gate.md +30 -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 +1 -1
- package/packages/cli/bin/haechi.mjs +92 -3
- package/packages/cli/runtime.mjs +49 -0
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/proxy/index.mjs +263 -28
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +127 -12
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
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1153
|
+
if (!value) {
|
|
956
1154
|
continue;
|
|
957
1155
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
next
|
|
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
|
-
|
|
1047
|
-
|
|
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
|
}
|
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)) {
|