haechi 0.5.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.
- package/README.ko.md +37 -0
- package/README.md +37 -0
- package/docs/README.md +1 -0
- package/docs/current/api-stability.ko.md +3 -1
- package/docs/current/api-stability.md +3 -1
- package/docs/current/configuration.ko.md +25 -2
- package/docs/current/configuration.md +25 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/risk-register-release-gate.ko.md +2 -2
- package/docs/current/risk-register-release-gate.md +3 -2
- package/docs/current/threat-model.ko.md +3 -1
- package/docs/current/threat-model.md +3 -1
- package/haechi.config.example.json +10 -0
- package/package.json +3 -2
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +91 -6
- package/packages/cli/runtime.mjs +103 -5
- package/packages/core/index.mjs +18 -7
- package/packages/policy/index.mjs +82 -0
- package/packages/proxy/index.mjs +134 -8
package/packages/proxy/index.mjs
CHANGED
|
@@ -7,24 +7,56 @@ export const DEFAULT_PROXY_PORT = 1016;
|
|
|
7
7
|
export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
|
|
8
8
|
assertSafeProxyBind({ host, allowRemoteBind });
|
|
9
9
|
const { haechi, config, protocolAdapter } = runtime;
|
|
10
|
+
const rateLimiter = createRateLimiter();
|
|
10
11
|
|
|
11
12
|
const server = createServer(async (request, response) => {
|
|
12
13
|
try {
|
|
13
14
|
if (request.method === "GET" && request.url === "/__haechi/health") {
|
|
15
|
+
// Intentionally unauthenticated; exposes only the mode.
|
|
14
16
|
writeJson(response, 200, { ok: true, mode: config.mode });
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
assertRelativeProxyTarget(request.url);
|
|
19
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
|
+
|
|
20
36
|
const body = await readBody(request, {
|
|
21
37
|
maxBytes: config.limits.maxRequestBytes
|
|
22
38
|
});
|
|
23
39
|
const json = parseJsonBody(body);
|
|
24
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
|
+
|
|
25
57
|
if (isStreamingRequest(json, routeContext)) {
|
|
26
58
|
if (config.streaming.requestMode === "inspect") {
|
|
27
|
-
await handleInspectedStream({ runtime, request, response, routeContext, json });
|
|
59
|
+
await handleInspectedStream({ runtime, request, response, routeContext, json, authContext });
|
|
28
60
|
return;
|
|
29
61
|
}
|
|
30
62
|
|
|
@@ -32,6 +64,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
32
64
|
await recordProxyDecision({
|
|
33
65
|
runtime,
|
|
34
66
|
routeContext,
|
|
67
|
+
identity,
|
|
68
|
+
profile,
|
|
35
69
|
decision: "streaming_request_pass_through",
|
|
36
70
|
reason: "streaming_request_pass_through",
|
|
37
71
|
enforced: false,
|
|
@@ -59,6 +93,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
59
93
|
const result = routeContext.protectRequest
|
|
60
94
|
? await haechi.protectJson(json, {
|
|
61
95
|
...routeContext,
|
|
96
|
+
...authContext,
|
|
62
97
|
operation: `request:${routeContext.operation}`,
|
|
63
98
|
direction: "request",
|
|
64
99
|
mode: config.policy.mode ?? config.mode
|
|
@@ -85,6 +120,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
85
120
|
upstreamResponse,
|
|
86
121
|
routeContext,
|
|
87
122
|
runtime,
|
|
123
|
+
authContext,
|
|
88
124
|
issuedTokens: result.issuedTokens ?? []
|
|
89
125
|
});
|
|
90
126
|
|
|
@@ -120,7 +156,89 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
120
156
|
};
|
|
121
157
|
}
|
|
122
158
|
|
|
123
|
-
|
|
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 = {} }) {
|
|
124
242
|
const { haechi, config } = runtime;
|
|
125
243
|
|
|
126
244
|
// Inspection needs to know the wire format and delta channel for this route.
|
|
@@ -137,6 +255,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
137
255
|
const requestResult = routeContext.protectRequest
|
|
138
256
|
? await haechi.protectJson(json, {
|
|
139
257
|
...routeContext,
|
|
258
|
+
...authContext,
|
|
140
259
|
operation: `request:${routeContext.operation}`,
|
|
141
260
|
direction: "request",
|
|
142
261
|
mode: config.policy.mode ?? config.mode
|
|
@@ -162,6 +281,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
162
281
|
const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
|
|
163
282
|
const protector = haechi.createStreamProtector({
|
|
164
283
|
...routeContext,
|
|
284
|
+
...authContext,
|
|
165
285
|
operation: `response-stream:${routeContext.operation}`,
|
|
166
286
|
direction: "response",
|
|
167
287
|
mode: streamMode,
|
|
@@ -177,7 +297,10 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
177
297
|
protector
|
|
178
298
|
});
|
|
179
299
|
|
|
180
|
-
await recordStreamDecision({
|
|
300
|
+
await recordStreamDecision({
|
|
301
|
+
runtime, routeContext, blocked, summary, mode: streamMode,
|
|
302
|
+
identity: authContext.identity ?? null, profile: authContext.profile ?? null
|
|
303
|
+
});
|
|
181
304
|
response.end();
|
|
182
305
|
}
|
|
183
306
|
|
|
@@ -200,7 +323,7 @@ async function* emptyAsyncIterable() {
|
|
|
200
323
|
// No upstream body to inspect.
|
|
201
324
|
}
|
|
202
325
|
|
|
203
|
-
async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode }) {
|
|
326
|
+
async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode, identity = null, profile = null }) {
|
|
204
327
|
if (typeof runtime.auditSink?.record !== "function") {
|
|
205
328
|
return;
|
|
206
329
|
}
|
|
@@ -210,7 +333,8 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
|
|
|
210
333
|
protocol: routeContext?.protocol ?? "proxy",
|
|
211
334
|
operation: `response-stream:${routeContext?.operation ?? "unknown"}`,
|
|
212
335
|
mode,
|
|
213
|
-
identity
|
|
336
|
+
identity,
|
|
337
|
+
profile,
|
|
214
338
|
enforced: !["dry-run", "report-only"].includes(mode),
|
|
215
339
|
blocked,
|
|
216
340
|
decision: blocked ? "stream_blocked" : "stream_inspected",
|
|
@@ -221,7 +345,7 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
|
|
|
221
345
|
});
|
|
222
346
|
}
|
|
223
347
|
|
|
224
|
-
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
|
|
348
|
+
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [] }) {
|
|
225
349
|
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
226
350
|
|
|
227
351
|
if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
|
|
@@ -311,6 +435,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, i
|
|
|
311
435
|
|
|
312
436
|
const result = await runtime.haechi.protectJson(json, {
|
|
313
437
|
...routeContext,
|
|
438
|
+
...authContext,
|
|
314
439
|
operation: `response:${routeContext.operation}`,
|
|
315
440
|
direction: "response",
|
|
316
441
|
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
@@ -595,7 +720,7 @@ async function cancelReader(reader) {
|
|
|
595
720
|
}
|
|
596
721
|
}
|
|
597
722
|
|
|
598
|
-
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
|
|
723
|
+
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked, identity = null, profile = null }) {
|
|
599
724
|
if (typeof runtime.auditSink?.record !== "function") {
|
|
600
725
|
return;
|
|
601
726
|
}
|
|
@@ -606,7 +731,8 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
|
|
|
606
731
|
protocol: routeContext?.protocol ?? "proxy",
|
|
607
732
|
operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
|
|
608
733
|
mode: runtime.config.policy.mode ?? runtime.config.mode,
|
|
609
|
-
identity
|
|
734
|
+
identity,
|
|
735
|
+
profile,
|
|
610
736
|
enforced,
|
|
611
737
|
blocked,
|
|
612
738
|
decision,
|