haechi 0.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/LICENSE +154 -0
- package/README.md +102 -0
- package/SECURITY.md +31 -0
- package/docs/README.md +35 -0
- package/docs/current/api-stability.ko.md +48 -0
- package/docs/current/api-stability.md +48 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
- package/docs/current/global-privacy-compliance-review.ko.md +110 -0
- package/docs/current/global-privacy-compliance-review.md +110 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
- package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
- package/docs/current/mvp-0.1-implementation-scope.md +79 -0
- package/docs/current/open-source-modular-architecture.ko.md +387 -0
- package/docs/current/open-source-modular-architecture.md +387 -0
- package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
- package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
- package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
- package/docs/current/privacy-filtering-policy-draft.md +307 -0
- package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
- package/docs/current/release-0.2-implementation-scope.md +46 -0
- package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
- package/docs/current/release-0.3-implementation-scope.md +86 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
- package/docs/current/release-0.3.2-hardening-scope.md +64 -0
- package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
- package/docs/current/release-0.4-implementation-scope.md +121 -0
- package/docs/current/release-process.ko.md +48 -0
- package/docs/current/release-process.md +48 -0
- package/docs/current/risk-register-release-gate.ko.md +154 -0
- package/docs/current/risk-register-release-gate.md +154 -0
- package/docs/current/shared-responsibility.ko.md +38 -0
- package/docs/current/shared-responsibility.md +38 -0
- package/docs/current/threat-model.ko.md +68 -0
- package/docs/current/threat-model.md +68 -0
- package/examples/llm-prompt-filtering/input.json +13 -0
- package/examples/plugins/custom-filter.plugin.json +29 -0
- package/haechi.config.example.json +70 -0
- package/package.json +74 -0
- package/packages/audit/index.mjs +262 -0
- package/packages/cli/bin/haechi.mjs +341 -0
- package/packages/cli/runtime.mjs +287 -0
- package/packages/core/index.mjs +309 -0
- package/packages/crypto/index.mjs +142 -0
- package/packages/filter/index.mjs +189 -0
- package/packages/mcp-stdio/index.mjs +105 -0
- package/packages/plugin/index.mjs +83 -0
- package/packages/policy/index.mjs +165 -0
- package/packages/policy-bundle/index.mjs +91 -0
- package/packages/privacy-profiles/index.mjs +92 -0
- package/packages/protocol-adapters/index.mjs +111 -0
- package/packages/proxy/index.mjs +534 -0
- package/packages/token-vault/index.mjs +262 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROXY_PORT = 1016;
|
|
5
|
+
|
|
6
|
+
export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
|
|
7
|
+
assertSafeProxyBind({ host, allowRemoteBind });
|
|
8
|
+
const { haechi, config, protocolAdapter } = runtime;
|
|
9
|
+
|
|
10
|
+
const server = createServer(async (request, response) => {
|
|
11
|
+
try {
|
|
12
|
+
if (request.method === "GET" && request.url === "/__haechi/health") {
|
|
13
|
+
writeJson(response, 200, { ok: true, mode: config.mode });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assertRelativeProxyTarget(request.url);
|
|
18
|
+
const routeContext = protocolAdapter.classifyRequest(request);
|
|
19
|
+
const body = await readBody(request, {
|
|
20
|
+
maxBytes: config.limits.maxRequestBytes
|
|
21
|
+
});
|
|
22
|
+
const json = parseJsonBody(body);
|
|
23
|
+
|
|
24
|
+
if (isStreamingRequest(json, routeContext)) {
|
|
25
|
+
if (config.streaming.requestMode === "pass-through") {
|
|
26
|
+
await recordProxyDecision({
|
|
27
|
+
runtime,
|
|
28
|
+
routeContext,
|
|
29
|
+
decision: "streaming_request_pass_through",
|
|
30
|
+
reason: "streaming_request_pass_through",
|
|
31
|
+
enforced: false,
|
|
32
|
+
blocked: false
|
|
33
|
+
});
|
|
34
|
+
const upstreamResponse = await forward({
|
|
35
|
+
upstream: config.target.upstream,
|
|
36
|
+
request,
|
|
37
|
+
body,
|
|
38
|
+
timeoutMs: config.limits.upstreamTimeoutMs
|
|
39
|
+
});
|
|
40
|
+
const { body: rawBody } = await readUpstreamBody(upstreamResponse);
|
|
41
|
+
response.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers.entries()));
|
|
42
|
+
response.end(rawBody);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
writeJson(response, 501, {
|
|
47
|
+
error: "haechi_streaming_unsupported",
|
|
48
|
+
message: "Streaming requests are blocked unless streaming.requestMode is explicitly set to pass-through"
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = routeContext.protectRequest
|
|
54
|
+
? await haechi.protectJson(json, {
|
|
55
|
+
...routeContext,
|
|
56
|
+
operation: `request:${routeContext.operation}`,
|
|
57
|
+
mode: config.policy.mode ?? config.mode
|
|
58
|
+
})
|
|
59
|
+
: { payload: json, blocked: false };
|
|
60
|
+
|
|
61
|
+
if (result.blocked) {
|
|
62
|
+
writeJson(response, 403, {
|
|
63
|
+
error: "haechi_policy_block",
|
|
64
|
+
summary: result.summary,
|
|
65
|
+
auditId: result.auditEvent.id
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const upstreamResponse = await forward({
|
|
71
|
+
upstream: config.target.upstream,
|
|
72
|
+
request,
|
|
73
|
+
body: JSON.stringify(result.payload),
|
|
74
|
+
timeoutMs: config.limits.upstreamTimeoutMs
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const forwarded = await maybeProtectResponse({
|
|
78
|
+
upstreamResponse,
|
|
79
|
+
routeContext,
|
|
80
|
+
runtime
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
response.writeHead(forwarded.status, forwarded.headers);
|
|
84
|
+
response.end(forwarded.body);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const expected = typeof error?.statusCode === "number";
|
|
87
|
+
if (!expected) {
|
|
88
|
+
console.error(`haechi proxy internal error: ${error?.stack ?? error?.message ?? error}`);
|
|
89
|
+
}
|
|
90
|
+
writeJson(response, error.statusCode ?? 500, {
|
|
91
|
+
error: error.errorCode ?? "haechi_proxy_error",
|
|
92
|
+
message: expected ? error.message : "Internal proxy error"
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
server,
|
|
99
|
+
listen() {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
server.listen(port, host, () => {
|
|
102
|
+
const address = server.address();
|
|
103
|
+
resolve({ host: address.address, port: address.port });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime }) {
|
|
116
|
+
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
117
|
+
|
|
118
|
+
if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
|
|
119
|
+
const { body: rawBody } = await readUpstreamBody(upstreamResponse);
|
|
120
|
+
return {
|
|
121
|
+
status: upstreamResponse.status,
|
|
122
|
+
headers,
|
|
123
|
+
body: rawBody
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const responsePolicy = runtime.config.responseProtection;
|
|
128
|
+
const contentEncoding = headers["content-encoding"] ?? "";
|
|
129
|
+
const bodyRead = await readUpstreamBody(upstreamResponse, { maxBytes: responsePolicy.maxBytes });
|
|
130
|
+
|
|
131
|
+
if (bodyRead.tooLarge) {
|
|
132
|
+
return unprotectedResponseDecision({
|
|
133
|
+
reason: "response_body_too_large",
|
|
134
|
+
detail: `Response body exceeds responseProtection.maxBytes (${responsePolicy.maxBytes})`,
|
|
135
|
+
upstreamResponse,
|
|
136
|
+
headers,
|
|
137
|
+
rawBody: bodyRead.body,
|
|
138
|
+
responsePolicy,
|
|
139
|
+
routeContext,
|
|
140
|
+
runtime,
|
|
141
|
+
hardDeny: true
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rawBody = bodyRead.body;
|
|
146
|
+
|
|
147
|
+
if (rawBody.byteLength > responsePolicy.maxBytes) {
|
|
148
|
+
return unprotectedResponseDecision({
|
|
149
|
+
reason: "response_body_too_large",
|
|
150
|
+
detail: `Response body exceeds responseProtection.maxBytes (${responsePolicy.maxBytes})`,
|
|
151
|
+
upstreamResponse,
|
|
152
|
+
headers,
|
|
153
|
+
rawBody,
|
|
154
|
+
responsePolicy,
|
|
155
|
+
routeContext,
|
|
156
|
+
runtime,
|
|
157
|
+
hardDeny: true
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (contentEncoding && contentEncoding.toLowerCase() !== "identity" && !responsePolicy.allowCompressed) {
|
|
162
|
+
return unprotectedResponseDecision({
|
|
163
|
+
reason: "compressed_response",
|
|
164
|
+
detail: "Compressed responses cannot be inspected by responseProtection",
|
|
165
|
+
upstreamResponse,
|
|
166
|
+
headers,
|
|
167
|
+
rawBody,
|
|
168
|
+
responsePolicy,
|
|
169
|
+
routeContext,
|
|
170
|
+
runtime
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!isJson(headers["content-type"])) {
|
|
175
|
+
return unprotectedResponseDecision({
|
|
176
|
+
reason: "non_json_response",
|
|
177
|
+
detail: "Non-JSON responses cannot be inspected by responseProtection",
|
|
178
|
+
upstreamResponse,
|
|
179
|
+
headers,
|
|
180
|
+
rawBody,
|
|
181
|
+
responsePolicy,
|
|
182
|
+
routeContext,
|
|
183
|
+
runtime
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let json;
|
|
188
|
+
try {
|
|
189
|
+
json = JSON.parse(rawBody.toString("utf8"));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return unprotectedResponseDecision({
|
|
192
|
+
reason: "invalid_json_response",
|
|
193
|
+
detail: error.message,
|
|
194
|
+
upstreamResponse,
|
|
195
|
+
headers,
|
|
196
|
+
rawBody,
|
|
197
|
+
responsePolicy,
|
|
198
|
+
routeContext,
|
|
199
|
+
runtime
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const result = await runtime.haechi.protectJson(json, {
|
|
204
|
+
...routeContext,
|
|
205
|
+
operation: `response:${routeContext.operation}`,
|
|
206
|
+
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (result.blocked) {
|
|
210
|
+
return {
|
|
211
|
+
status: 502,
|
|
212
|
+
headers: { "content-type": "application/json" },
|
|
213
|
+
body: Buffer.from(`${JSON.stringify({
|
|
214
|
+
error: "haechi_response_policy_block",
|
|
215
|
+
summary: result.summary,
|
|
216
|
+
auditId: result.auditEvent.id
|
|
217
|
+
}, null, 2)}\n`)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
status: upstreamResponse.status,
|
|
223
|
+
headers: transformedJsonHeaders(headers),
|
|
224
|
+
body: Buffer.from(`${JSON.stringify(result.payload)}\n`)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function forward({ upstream, request, body, timeoutMs = null }) {
|
|
229
|
+
const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
|
|
230
|
+
try {
|
|
231
|
+
return await fetch(target, {
|
|
232
|
+
method: request.method,
|
|
233
|
+
headers: filteredHeaders(request.headers),
|
|
234
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
|
|
235
|
+
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
239
|
+
throw proxyError({
|
|
240
|
+
statusCode: 504,
|
|
241
|
+
errorCode: "haechi_upstream_timeout",
|
|
242
|
+
message: `Upstream did not respond within limits.upstreamTimeoutMs (${timeoutMs})`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
throw proxyError({
|
|
246
|
+
statusCode: 502,
|
|
247
|
+
errorCode: "haechi_upstream_unreachable",
|
|
248
|
+
message: "Upstream request failed"
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildUpstreamUrl({ upstream, requestUrl }) {
|
|
254
|
+
assertRelativeProxyTarget(requestUrl);
|
|
255
|
+
const parsed = new URL(requestUrl, "http://haechi.local");
|
|
256
|
+
return new URL(`${parsed.pathname}${parsed.search}`, upstream.endsWith("/") ? upstream : `${upstream}/`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function filteredHeaders(headers) {
|
|
260
|
+
const next = new Headers();
|
|
261
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
262
|
+
if (!value || ["host", "content-length"].includes(key.toLowerCase())) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(value)) {
|
|
266
|
+
for (const item of value) {
|
|
267
|
+
next.append(key, item);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
next.set(key, value);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
next.set("content-type", "application/json");
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readBody(request, { maxBytes }) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
let received = 0;
|
|
281
|
+
let rejected = false;
|
|
282
|
+
|
|
283
|
+
request.on("data", (chunk) => {
|
|
284
|
+
if (rejected) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
received += chunk.byteLength;
|
|
288
|
+
if (received > maxBytes) {
|
|
289
|
+
rejected = true;
|
|
290
|
+
reject(proxyError({
|
|
291
|
+
statusCode: 413,
|
|
292
|
+
errorCode: "haechi_request_body_too_large",
|
|
293
|
+
message: `Request body exceeds limits.maxRequestBytes (${maxBytes})`
|
|
294
|
+
}));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
chunks.push(chunk);
|
|
298
|
+
});
|
|
299
|
+
request.on("end", () => {
|
|
300
|
+
if (!rejected) {
|
|
301
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
request.on("error", (error) => {
|
|
305
|
+
if (!rejected) {
|
|
306
|
+
reject(error);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseJsonBody(body) {
|
|
313
|
+
if (!body) {
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
return JSON.parse(body);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw proxyError({
|
|
321
|
+
statusCode: 400,
|
|
322
|
+
errorCode: "haechi_invalid_json_request",
|
|
323
|
+
message: error.message
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function writeJson(response, status, body) {
|
|
329
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
330
|
+
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isJson(contentType = "") {
|
|
334
|
+
return contentType.toLowerCase().includes("application/json");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function transformedJsonHeaders(headers) {
|
|
338
|
+
const next = { ...headers, "content-type": "application/json" };
|
|
339
|
+
delete next["content-length"];
|
|
340
|
+
delete next["content-encoding"];
|
|
341
|
+
return next;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function unprotectedResponseDecision({
|
|
345
|
+
reason,
|
|
346
|
+
detail,
|
|
347
|
+
upstreamResponse,
|
|
348
|
+
headers,
|
|
349
|
+
rawBody,
|
|
350
|
+
responsePolicy,
|
|
351
|
+
routeContext,
|
|
352
|
+
runtime,
|
|
353
|
+
hardDeny = false
|
|
354
|
+
}) {
|
|
355
|
+
const allowed = responsePolicy.failureMode === "allow" && !hardDeny;
|
|
356
|
+
await recordProxyDecision({
|
|
357
|
+
runtime,
|
|
358
|
+
routeContext,
|
|
359
|
+
decision: allowed ? "response_unprotected_allowed" : "response_unprotected_blocked",
|
|
360
|
+
reason,
|
|
361
|
+
enforced: !allowed,
|
|
362
|
+
blocked: !allowed
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (allowed) {
|
|
366
|
+
return {
|
|
367
|
+
status: upstreamResponse.status,
|
|
368
|
+
headers,
|
|
369
|
+
body: rawBody
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
status: 502,
|
|
375
|
+
headers: { "content-type": "application/json" },
|
|
376
|
+
body: Buffer.from(`${JSON.stringify({
|
|
377
|
+
error: "haechi_response_unprotected",
|
|
378
|
+
reason,
|
|
379
|
+
message: detail
|
|
380
|
+
}, null, 2)}\n`)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function readUpstreamBody(upstreamResponse, { maxBytes = null } = {}) {
|
|
385
|
+
const contentLength = parseContentLength(upstreamResponse.headers.get("content-length"));
|
|
386
|
+
if (maxBytes && contentLength !== null && contentLength > maxBytes) {
|
|
387
|
+
void cancelUpstreamBody(upstreamResponse);
|
|
388
|
+
return {
|
|
389
|
+
body: Buffer.alloc(0),
|
|
390
|
+
tooLarge: true,
|
|
391
|
+
receivedBytes: contentLength
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!upstreamResponse.body) {
|
|
396
|
+
return {
|
|
397
|
+
body: Buffer.alloc(0),
|
|
398
|
+
tooLarge: false,
|
|
399
|
+
receivedBytes: 0
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const reader = upstreamResponse.body.getReader();
|
|
404
|
+
const chunks = [];
|
|
405
|
+
let receivedBytes = 0;
|
|
406
|
+
|
|
407
|
+
while (true) {
|
|
408
|
+
const { done, value } = await reader.read();
|
|
409
|
+
if (done) {
|
|
410
|
+
return {
|
|
411
|
+
body: Buffer.concat(chunks),
|
|
412
|
+
tooLarge: false,
|
|
413
|
+
receivedBytes
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
receivedBytes += value.byteLength;
|
|
418
|
+
if (maxBytes && receivedBytes > maxBytes) {
|
|
419
|
+
void cancelReader(reader);
|
|
420
|
+
return {
|
|
421
|
+
body: Buffer.concat(chunks),
|
|
422
|
+
tooLarge: true,
|
|
423
|
+
receivedBytes
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
chunks.push(Buffer.from(value));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function parseContentLength(value) {
|
|
431
|
+
if (!value) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const parsed = Number.parseInt(value, 10);
|
|
435
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function cancelUpstreamBody(upstreamResponse) {
|
|
439
|
+
try {
|
|
440
|
+
await upstreamResponse.body?.cancel();
|
|
441
|
+
} catch {
|
|
442
|
+
// Best-effort cancellation after a hard size cap decision.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function cancelReader(reader) {
|
|
447
|
+
try {
|
|
448
|
+
await reader.cancel();
|
|
449
|
+
} catch {
|
|
450
|
+
// Best-effort cancellation after a hard size cap decision.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
|
|
455
|
+
if (typeof runtime.auditSink?.record !== "function") {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
await runtime.auditSink.record({
|
|
460
|
+
id: randomUUID(),
|
|
461
|
+
timestamp: new Date().toISOString(),
|
|
462
|
+
protocol: routeContext?.protocol ?? "proxy",
|
|
463
|
+
operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
|
|
464
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode,
|
|
465
|
+
enforced,
|
|
466
|
+
blocked,
|
|
467
|
+
decision,
|
|
468
|
+
reason,
|
|
469
|
+
routeId: routeContext?.routeId ?? "unknown",
|
|
470
|
+
pathHash: routeContext?.path ? shortHash(routeContext.path) : null,
|
|
471
|
+
summary: {
|
|
472
|
+
detectionCount: 0,
|
|
473
|
+
byType: {},
|
|
474
|
+
byAction: {
|
|
475
|
+
[decision]: 1
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isStreamingRequest(value, routeContext = {}) {
|
|
482
|
+
if (!value || typeof value !== "object") {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
if (value.stream === true) {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
// Routes that stream unless explicitly disabled (e.g. Ollama /api/chat,
|
|
489
|
+
// /api/generate) are treated as streaming whenever stream !== false.
|
|
490
|
+
if (routeContext.streamingByDefault && value.stream !== false) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function proxyError({ statusCode, errorCode, message }) {
|
|
497
|
+
const error = new Error(message);
|
|
498
|
+
error.statusCode = statusCode;
|
|
499
|
+
error.errorCode = errorCode;
|
|
500
|
+
return error;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function assertRelativeProxyTarget(url) {
|
|
504
|
+
const target = String(url ?? "").trim();
|
|
505
|
+
if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(target) || target.startsWith("//")) {
|
|
506
|
+
throw proxyError({
|
|
507
|
+
statusCode: 400,
|
|
508
|
+
errorCode: "haechi_invalid_proxy_target",
|
|
509
|
+
message: "Proxy request target must be origin-form path, not an absolute URL"
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function shortHash(value) {
|
|
515
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function assertSafeProxyBind({ host = "127.0.0.1", allowRemoteBind = false } = {}) {
|
|
519
|
+
if (allowRemoteBind || isLoopbackHost(host)) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
throw new Error(`Refusing to bind Haechi proxy to non-loopback host ${host}. Use --allow-remote-bind only for explicitly secured environments.`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isLoopbackHost(host) {
|
|
527
|
+
const normalized = String(host).trim().toLowerCase();
|
|
528
|
+
return normalized === "localhost"
|
|
529
|
+
|| normalized === "::1"
|
|
530
|
+
|| normalized === "[::1]"
|
|
531
|
+
|| normalized === "0:0:0:0:0:0:0:1"
|
|
532
|
+
|| normalized === "127.0.0.1"
|
|
533
|
+
|| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized);
|
|
534
|
+
}
|