haechi 0.4.0 → 0.6.0

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.
@@ -1,31 +1,71 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import { inspectResponseStream } from "../stream-filter/index.mjs";
3
4
 
4
5
  export const DEFAULT_PROXY_PORT = 1016;
5
6
 
6
7
  export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
7
8
  assertSafeProxyBind({ host, allowRemoteBind });
8
9
  const { haechi, config, protocolAdapter } = runtime;
10
+ const rateLimiter = createRateLimiter();
9
11
 
10
12
  const server = createServer(async (request, response) => {
11
13
  try {
12
14
  if (request.method === "GET" && request.url === "/__haechi/health") {
15
+ // Intentionally unauthenticated; exposes only the mode.
13
16
  writeJson(response, 200, { ok: true, mode: config.mode });
14
17
  return;
15
18
  }
16
19
 
17
20
  assertRelativeProxyTarget(request.url);
18
21
  const routeContext = protocolAdapter.classifyRequest(request);
22
+
23
+ // Authenticate, resolve the policy profile, and rate-limit BEFORE reading
24
+ // the body, so a denied/throttled request cannot stream a large body.
25
+ const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter });
26
+ if (gate.denied) {
27
+ writeJson(response, gate.denied.status, {
28
+ error: gate.denied.error,
29
+ message: gate.denied.message
30
+ });
31
+ return;
32
+ }
33
+ const { identity, profile, policyEngine, modelAllowlist } = gate;
34
+ const authContext = { identity, profile, policyEngine };
35
+
19
36
  const body = await readBody(request, {
20
37
  maxBytes: config.limits.maxRequestBytes
21
38
  });
22
39
  const json = parseJsonBody(body);
23
40
 
41
+ // Model allowlist runs after body read (the model field is in the body).
42
+ if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
43
+ await recordProxyDecision({
44
+ runtime, routeContext, identity, profile,
45
+ decision: "model_not_allowed",
46
+ reason: `model:${json.model}`,
47
+ enforced: true,
48
+ blocked: true
49
+ });
50
+ writeJson(response, 403, {
51
+ error: "haechi_model_not_allowed",
52
+ message: `Model not allowed: ${json.model}`
53
+ });
54
+ return;
55
+ }
56
+
24
57
  if (isStreamingRequest(json, routeContext)) {
58
+ if (config.streaming.requestMode === "inspect") {
59
+ await handleInspectedStream({ runtime, request, response, routeContext, json, authContext });
60
+ return;
61
+ }
62
+
25
63
  if (config.streaming.requestMode === "pass-through") {
26
64
  await recordProxyDecision({
27
65
  runtime,
28
66
  routeContext,
67
+ identity,
68
+ profile,
29
69
  decision: "streaming_request_pass_through",
30
70
  reason: "streaming_request_pass_through",
31
71
  enforced: false,
@@ -45,7 +85,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
45
85
 
46
86
  writeJson(response, 501, {
47
87
  error: "haechi_streaming_unsupported",
48
- message: "Streaming requests are blocked unless streaming.requestMode is explicitly set to pass-through"
88
+ message: "Streaming requests are blocked unless streaming.requestMode is set to pass-through or inspect"
49
89
  });
50
90
  return;
51
91
  }
@@ -53,6 +93,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
53
93
  const result = routeContext.protectRequest
54
94
  ? await haechi.protectJson(json, {
55
95
  ...routeContext,
96
+ ...authContext,
56
97
  operation: `request:${routeContext.operation}`,
57
98
  direction: "request",
58
99
  mode: config.policy.mode ?? config.mode
@@ -79,6 +120,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
79
120
  upstreamResponse,
80
121
  routeContext,
81
122
  runtime,
123
+ authContext,
82
124
  issuedTokens: result.issuedTokens ?? []
83
125
  });
84
126
 
@@ -114,7 +156,196 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
114
156
  };
115
157
  }
116
158
 
117
- async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
159
+ // Authenticate resolve policy profile rate-limit. Returns the request's
160
+ // identity/profile/policyEngine/modelAllowlist, or a denial. Auth is required
161
+ // exactly when an authProvider is configured (auth.provider !== "none").
162
+ async function authorizeRequest({ runtime, request, routeContext, rateLimiter }) {
163
+ const { authProvider, policyProfiles } = runtime;
164
+ let identity = null;
165
+
166
+ if (authProvider) {
167
+ try {
168
+ identity = await authProvider.authenticate(request);
169
+ } catch {
170
+ await recordAuthDenied({ runtime, routeContext, reason: "provider_error" });
171
+ return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication failed" } };
172
+ }
173
+ if (!identity) {
174
+ const reason = hasBearerHeader(request) ? "invalid_token" : "no_token";
175
+ await recordAuthDenied({ runtime, routeContext, reason });
176
+ return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication required" } };
177
+ }
178
+ }
179
+
180
+ const resolved = policyProfiles.resolve(identity);
181
+
182
+ if (resolved.rate && resolved.rate.requestsPerMinute) {
183
+ const key = identity?.id ?? "anonymous";
184
+ if (!rateLimiter.allow(key, resolved.rate.requestsPerMinute)) {
185
+ await recordProxyDecision({
186
+ runtime, routeContext, identity, profile: resolved.profile,
187
+ decision: "rate_limited",
188
+ reason: `rate:${resolved.rate.requestsPerMinute}`,
189
+ enforced: true,
190
+ blocked: true
191
+ });
192
+ return { denied: { status: 429, error: "haechi_rate_limited", message: "Rate limit exceeded" } };
193
+ }
194
+ }
195
+
196
+ return {
197
+ identity,
198
+ profile: resolved.profile,
199
+ policyEngine: resolved.policyEngine,
200
+ modelAllowlist: resolved.modelAllowlist
201
+ };
202
+ }
203
+
204
+ function hasBearerHeader(request) {
205
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
206
+ return typeof header === "string" && /^Bearer\s+/i.test(header.trim());
207
+ }
208
+
209
+ function createRateLimiter() {
210
+ // In-memory fixed-window counter. Per-process: resets on restart, not shared
211
+ // across replicas — acceptable for a single-process self-hosted preview.
212
+ const windows = new Map();
213
+ const windowMs = 60000;
214
+ return {
215
+ allow(key, limit) {
216
+ const now = Date.now();
217
+ const slot = windows.get(key);
218
+ if (!slot || now - slot.windowStart >= windowMs) {
219
+ windows.set(key, { windowStart: now, count: 1 });
220
+ return true;
221
+ }
222
+ if (slot.count >= limit) {
223
+ return false;
224
+ }
225
+ slot.count += 1;
226
+ return true;
227
+ }
228
+ };
229
+ }
230
+
231
+ async function recordAuthDenied({ runtime, routeContext, reason }) {
232
+ await recordProxyDecision({
233
+ runtime, routeContext, identity: null, profile: null,
234
+ decision: "auth_denied",
235
+ reason,
236
+ enforced: true,
237
+ blocked: true
238
+ });
239
+ }
240
+
241
+ async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {} }) {
242
+ const { haechi, config } = runtime;
243
+
244
+ // Inspection needs to know the wire format and delta channel for this route.
245
+ if (!routeContext.streaming) {
246
+ writeJson(response, 501, {
247
+ error: "haechi_streaming_uninspectable_route",
248
+ message: `streaming.requestMode is "inspect" but route ${routeContext.routeId} has no known streaming format`
249
+ });
250
+ return;
251
+ }
252
+
253
+ // The request body is ordinary JSON even when the response streams, so it is
254
+ // protected like any other request.
255
+ const requestResult = routeContext.protectRequest
256
+ ? await haechi.protectJson(json, {
257
+ ...routeContext,
258
+ ...authContext,
259
+ operation: `request:${routeContext.operation}`,
260
+ direction: "request",
261
+ mode: config.policy.mode ?? config.mode
262
+ })
263
+ : { payload: json, blocked: false };
264
+
265
+ if (requestResult.blocked) {
266
+ writeJson(response, 403, {
267
+ error: "haechi_policy_block",
268
+ summary: requestResult.summary,
269
+ auditId: requestResult.auditEvent.id
270
+ });
271
+ return;
272
+ }
273
+
274
+ const upstreamResponse = await forward({
275
+ upstream: config.target.upstream,
276
+ request,
277
+ body: JSON.stringify(requestResult.payload),
278
+ timeoutMs: config.limits.upstreamTimeoutMs
279
+ });
280
+
281
+ const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
282
+ const protector = haechi.createStreamProtector({
283
+ ...routeContext,
284
+ ...authContext,
285
+ operation: `response-stream:${routeContext.operation}`,
286
+ direction: "response",
287
+ mode: streamMode,
288
+ maxMatchBytes: config.streaming.maxMatchBytes
289
+ });
290
+
291
+ response.writeHead(upstreamResponse.status, streamingResponseHeaders(upstreamResponse));
292
+
293
+ const { blocked, summary } = await inspectResponseStream({
294
+ source: upstreamResponse.body ?? emptyAsyncIterable(),
295
+ sink: nodeResponseSink(response),
296
+ streaming: routeContext.streaming,
297
+ protector
298
+ });
299
+
300
+ await recordStreamDecision({
301
+ runtime, routeContext, blocked, summary, mode: streamMode,
302
+ identity: authContext.identity ?? null, profile: authContext.profile ?? null
303
+ });
304
+ response.end();
305
+ }
306
+
307
+ function streamingResponseHeaders(upstreamResponse) {
308
+ const headers = Object.fromEntries(upstreamResponse.headers.entries());
309
+ delete headers["content-length"];
310
+ delete headers["content-encoding"];
311
+ return headers;
312
+ }
313
+
314
+ function nodeResponseSink(response) {
315
+ return {
316
+ write(text) {
317
+ response.write(text);
318
+ }
319
+ };
320
+ }
321
+
322
+ async function* emptyAsyncIterable() {
323
+ // No upstream body to inspect.
324
+ }
325
+
326
+ async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode, identity = null, profile = null }) {
327
+ if (typeof runtime.auditSink?.record !== "function") {
328
+ return;
329
+ }
330
+ await runtime.auditSink.record({
331
+ id: randomUUID(),
332
+ timestamp: new Date().toISOString(),
333
+ protocol: routeContext?.protocol ?? "proxy",
334
+ operation: `response-stream:${routeContext?.operation ?? "unknown"}`,
335
+ mode,
336
+ identity,
337
+ profile,
338
+ enforced: !["dry-run", "report-only"].includes(mode),
339
+ blocked,
340
+ decision: blocked ? "stream_blocked" : "stream_inspected",
341
+ reason: blocked ? "stream_policy_block" : "stream_inspected",
342
+ routeId: routeContext?.routeId ?? "unknown",
343
+ pathHash: routeContext?.path ? shortHash(routeContext.path) : null,
344
+ summary
345
+ });
346
+ }
347
+
348
+ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [] }) {
118
349
  const headers = Object.fromEntries(upstreamResponse.headers.entries());
119
350
 
120
351
  if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
@@ -204,6 +435,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, i
204
435
 
205
436
  const result = await runtime.haechi.protectJson(json, {
206
437
  ...routeContext,
438
+ ...authContext,
207
439
  operation: `response:${routeContext.operation}`,
208
440
  direction: "response",
209
441
  mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
@@ -488,7 +720,7 @@ async function cancelReader(reader) {
488
720
  }
489
721
  }
490
722
 
491
- async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
723
+ async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked, identity = null, profile = null }) {
492
724
  if (typeof runtime.auditSink?.record !== "function") {
493
725
  return;
494
726
  }
@@ -499,7 +731,8 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
499
731
  protocol: routeContext?.protocol ?? "proxy",
500
732
  operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
501
733
  mode: runtime.config.policy.mode ?? runtime.config.mode,
502
- identity: null,
734
+ identity,
735
+ profile,
503
736
  enforced,
504
737
  blocked,
505
738
  decision,
@@ -0,0 +1,194 @@
1
+ // SSE / NDJSON streaming response inspection.
2
+ //
3
+ // Frames are parsed incrementally, the primary delta-text channel is run
4
+ // through a bounded sliding buffer (cross-frame matches caught up to
5
+ // streaming.maxMatchBytes), and all other string leaves in a frame get
6
+ // within-frame protection. The whole stream is audited once at the end.
7
+
8
+ const SSE_DONE = "[DONE]";
9
+
10
+ export async function inspectResponseStream({ source, sink, streaming, protector, format }) {
11
+ const wireFormat = format ?? streaming?.format ?? "ndjson";
12
+ const deltaPath = streaming?.deltaPath ?? null;
13
+ const decoder = new TextDecoder("utf-8");
14
+ const frames = createFrameSplitter(wireFormat);
15
+
16
+ let blocked = false;
17
+
18
+ async function handleFrame(raw) {
19
+ const frame = { raw, body: raw.trim() };
20
+ const parsed = parseFrame(frame, wireFormat);
21
+ if (!parsed.ok) {
22
+ // Non-JSON frame (e.g. `data: [DONE]`, comments, keep-alives): pass
23
+ // through verbatim — there is nothing to inspect.
24
+ sink.write(frame.raw);
25
+ return;
26
+ }
27
+
28
+ const json = parsed.json;
29
+ let deltaText = null;
30
+ if (deltaPath) {
31
+ const found = getByPath(json, deltaPath);
32
+ if (typeof found === "string") {
33
+ deltaText = found;
34
+ setByPath(json, deltaPath, "");
35
+ }
36
+ }
37
+
38
+ // Within-frame protection for everything except the delta channel.
39
+ const extras = await protector.protectFrameExtras(json);
40
+ if (extras.blocked) {
41
+ blocked = true;
42
+ return;
43
+ }
44
+ const frameObject = extras.value;
45
+
46
+ if (deltaText !== null) {
47
+ const pushed = await protector.push(deltaText);
48
+ if (pushed.blocked) {
49
+ blocked = true;
50
+ return;
51
+ }
52
+ setByPath(frameObject, deltaPath, pushed.text);
53
+ }
54
+
55
+ sink.write(serializeFrame(frameObject, wireFormat, frame));
56
+ }
57
+
58
+ for await (const chunk of source) {
59
+ for (const frame of frames.push(decoder.decode(chunk, { stream: true }))) {
60
+ await handleFrame(frame);
61
+ if (blocked) {
62
+ break;
63
+ }
64
+ }
65
+ if (blocked) {
66
+ break;
67
+ }
68
+ }
69
+
70
+ if (!blocked) {
71
+ for (const frame of frames.end(decoder.decode())) {
72
+ await handleFrame(frame);
73
+ if (blocked) {
74
+ break;
75
+ }
76
+ }
77
+ }
78
+
79
+ if (!blocked) {
80
+ // Flush the held tail of the delta buffer as a synthesized final frame.
81
+ const flushed = await protector.flush();
82
+ if (flushed.blocked) {
83
+ blocked = true;
84
+ } else if (flushed.text && deltaPath) {
85
+ sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
86
+ }
87
+ }
88
+
89
+ // The caller closes the sink AFTER recording the stream decision, so the
90
+ // audit write is durable before the client connection ends.
91
+ return { blocked, summary: protector.summary() };
92
+ }
93
+
94
+ function createFrameSplitter(format) {
95
+ const delimiter = format === "sse" ? "\n\n" : "\n";
96
+ let buffer = "";
97
+ return {
98
+ // Append text and return the raw text of every complete frame now
99
+ // available; the trailing partial is retained for the next push.
100
+ push(text) {
101
+ buffer += text;
102
+ const out = [];
103
+ let index;
104
+ while ((index = buffer.indexOf(delimiter)) !== -1) {
105
+ const raw = buffer.slice(0, index + delimiter.length);
106
+ buffer = buffer.slice(index + delimiter.length);
107
+ if (raw.trim()) {
108
+ out.push(raw);
109
+ }
110
+ }
111
+ return out;
112
+ },
113
+ // Flush any trailing partial frame at end of stream.
114
+ end(text) {
115
+ buffer += text;
116
+ const remainder = buffer;
117
+ buffer = "";
118
+ return remainder.trim() ? [remainder] : [];
119
+ }
120
+ };
121
+ }
122
+
123
+ function parseFrame(frame, format) {
124
+ if (!frame) {
125
+ return { ok: false };
126
+ }
127
+ let payload = frame.body;
128
+ if (format === "sse") {
129
+ const dataLines = payload
130
+ .split("\n")
131
+ .filter((line) => line.startsWith("data:"))
132
+ .map((line) => line.slice(5).trim());
133
+ if (dataLines.length === 0) {
134
+ return { ok: false };
135
+ }
136
+ payload = dataLines.join("");
137
+ if (payload === SSE_DONE) {
138
+ return { ok: false };
139
+ }
140
+ }
141
+ try {
142
+ return { ok: true, json: JSON.parse(payload) };
143
+ } catch {
144
+ return { ok: false };
145
+ }
146
+ }
147
+
148
+ function serializeFrame(json, format, original) {
149
+ const body = JSON.stringify(json);
150
+ if (format === "sse") {
151
+ return `data: ${body}\n\n`;
152
+ }
153
+ // NDJSON: preserve the original trailing newline style when available.
154
+ return original && original.raw.endsWith("\n") ? `${body}\n` : `${body}\n`;
155
+ }
156
+
157
+ export function getByPath(value, path) {
158
+ let current = value;
159
+ for (const part of path) {
160
+ if (current == null) {
161
+ return undefined;
162
+ }
163
+ current = current[part];
164
+ }
165
+ return current;
166
+ }
167
+
168
+ export function setByPath(value, path, next) {
169
+ let current = value;
170
+ for (let index = 0; index < path.length - 1; index += 1) {
171
+ const part = path[index];
172
+ if (current[part] == null || typeof current[part] !== "object") {
173
+ return false;
174
+ }
175
+ current = current[part];
176
+ }
177
+ if (current == null || typeof current !== "object") {
178
+ return false;
179
+ }
180
+ current[path[path.length - 1]] = next;
181
+ return true;
182
+ }
183
+
184
+ export function buildPathObject(path, leaf) {
185
+ const root = typeof path[0] === "number" ? [] : {};
186
+ let current = root;
187
+ for (let index = 0; index < path.length - 1; index += 1) {
188
+ const nextIsIndex = typeof path[index + 1] === "number";
189
+ current[path[index]] = nextIsIndex ? [] : {};
190
+ current = current[path[index]];
191
+ }
192
+ current[path[path.length - 1]] = leaf;
193
+ return root;
194
+ }