haechi 1.1.0 → 1.1.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.
@@ -1,8 +1,7 @@
1
1
  # Haechi Shared Responsibility
2
2
 
3
- - Status: Draft 0.1
3
+ - Status: Living document (tracks core 1.1.x)
4
4
  - Date: 2026-06-10
5
- - Target version: 0.3.2
6
5
 
7
6
  ## 1. Responsibility Matrix
8
7
 
@@ -15,7 +14,7 @@
15
14
  | TokenVault | Encrypted storage, reveal blocked by default, purge | Reveal approval workflow, DSAR/retention operations |
16
15
  | Audit | Plaintext removal, hash chain | Append-only storage, backup, retention period, external signing |
17
16
  | Key custody | Local dev key, external crypto provider contract | KMS/HSM/Vault adapter implementation, rotation, access review |
18
- | Plugin | Manifest validation, dynamic runtime blocked | Plugin code review, do not execute before sandbox is available |
17
+ | Plugin | Manifest validation; dynamic loading lifted narrowly for signed + sandboxed `authProvider` plugins (worker-isolated 1.0 / process-isolated 1.1) | Curate trust anchors/pins/revocation; prefer `process-isolated`; review plugin code |
19
18
  | MCP | JSON-RPC/method allowlist | MCP server auth, resource consent, env secret allowlist |
20
19
  | Privacy profile | KR/EU/US baseline actions | Legal review, data residency, cross-border transfer evidence |
21
20
 
@@ -36,3 +35,13 @@
36
35
  5. Send the audit sink to an append-only or externally signed storage backend.
37
36
  6. Document the TokenVault reveal approval, retention, and deletion procedures.
38
37
  7. Calibrate privacy profiles based on legal review findings.
38
+ 8. For more than one replica, supply the shared infrastructure in §4 (front-door rate limit, per-replica audit paths, shared token vault).
39
+
40
+ ## 4. Horizontal scale / multiple replicas
41
+
42
+ Haechi's stateful controls are single-process by design. Running 2+ replicas behind a load balancer **silently weakens** them unless the operator supplies shared infrastructure:
43
+
44
+ - **Rate limit** is per-process and in-memory — total throughput multiplies by the replica count. Enforce a per-identity limit at a shared front door, or inject a shared-store `rateLimiter`.
45
+ - **Audit hash chain + anchor** are single-writer. Give each replica its **own** `audit.path` (and anchor path); never share one audit file across replicas, or the chain forks into an unverifiable state.
46
+ - **TokenVault and the auth store** are whole-file local stores — correct for one host, but not a shared multi-writer store. For multi-replica tokenization, inject a shared `tokenVault`.
47
+ - File locking relies on `O_EXCL` + atomic rename, which do not hold on NFS / shared filesystems — keep these stores on local disk.
@@ -1,12 +1,11 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - 문서 상태: Draft 0.1
3
+ - 문서 상태: Living document(core 1.1.x 추적)
4
4
  - 작성일: 2026-06-10
5
- - 기준 버전: 1.0.0
6
5
 
7
6
  ## 1. 보호 대상
8
7
 
9
- Haechi가 보호하려는 주요 자산은 다음이다.
8
+ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
10
9
 
11
10
  | 자산 | 예시 | 보호 목표 |
12
11
  |---|---|---|
@@ -24,7 +23,7 @@ Haechi가 보호하려는 주요 자산은 다음이다.
24
23
  | CLI local process | 개발자 로컬 신뢰 | dev key 경고, dry-run 기본값 |
25
24
  | HTTP proxy listener | 비신뢰 client 입력 | loopback bind 기본, remote bind 명시 플래그 |
26
25
  | Upstream model/tool server | 비신뢰 또는 부분 신뢰 | request/response protection, uninspectable response fail-closed |
27
- | Streaming response | 검사(bounded) 또는 차단 | `inspect` 모드는 bounded cross-frame 버퍼로 SSE/NDJSON을 stream-filter함; `block`(기본값)은 거부 |
26
+ | Streaming response | 검사(bounded) 또는 차단 | `inspect` 모드는 bounded cross-frame 버퍼로 SSE/NDJSON을 stream-filter합니다. `block`(기본값)은 거부합니다 |
28
27
  | MCP stdio peer | 부분 신뢰 | JSON-RPC 2.0 요구, method allowlist |
29
28
  | Local filesystem | 부분 신뢰 | local key/token vault 0600, audit hash chain |
30
29
  | External provider/plugin | 비신뢰 | provider method contract, plugin manifest-only gate |
@@ -34,8 +33,8 @@ Haechi가 보호하려는 주요 자산은 다음이다.
34
33
  | 위협 | 영향 | 현재 통제 |
35
34
  |---|---|---|
36
35
  | 인터넷 노출 proxy | 인증 없는 LLM gateway | non-loopback bind 기본 실패 |
37
- | streaming 우회 | SSE/NDJSON 평문 유출 | `inspect` 모드는 SSE/NDJSON을 stream-filter함; `block`(기본값)은 거부; `pass-through`는 명시적으로 감사된 opt-out |
38
- | Ollama 암묵 streaming 우회 | `stream` 생략 시 NDJSON 평문 유출 | `/api/chat`·`/api/generate`는 `stream: false` 명시 없으면 streaming으로 간주해 기본 차단 |
36
+ | streaming 우회 | SSE/NDJSON 평문 유출 | `inspect` 모드는 SSE/NDJSON을 stream-filter합니다. `block`(기본값)은 거부하고, `pass-through`는 명시적으로 감사된 opt-out입니다 |
37
+ | Ollama 암묵 streaming 우회 | `stream` 생략 시 NDJSON 평문 유출 | `/api/chat`·`/api/generate`는 `stream: false`를 명시하지 않으면 streaming으로 간주해 기본 차단합니다 |
39
38
  | 비JSON/압축/대용량 응답 | responseProtection 우회 | fail-closed response policy |
40
39
  | token reveal 남용 | tokenized PII 복원 | revealPolicy 기본 disabled, reveal/purge 결정 audit 기록 |
41
40
  | audit 변조 | 감사 증거 신뢰 저하 | SHA-256 hash chain |
@@ -65,7 +64,7 @@ Haechi가 보호하려는 주요 자산은 다음이다.
65
64
  | 토큰 엔드포인트 POST(및 Vault `fetch`)를 통한 broker SSRF — cloud metadata (0.9) | discovery와 request 사이에 `169.254.169.254`로 DNS-rebind되는 `token_endpoint`(또는 운영자 제공 `VAULT_ADDR`)가 instance-metadata 자격증명을 유출 | 모든 egress(discovery GET, 공유 verifier 경유 JWKS GET, token-exchange POST, end-session redirect, `haechi-crypto-kms` Vault `fetch`)가 **request 직전**(post-DNS) `lookup` 후 `isBlockedAddress` 재검사를 `redirect: "error"`·bounded body·timeout과 함께 수행. 운영자 신뢰 엔드포인트에 한함 |
66
65
  | audit/로그로의 token/secret leak (broker) (0.9) | ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, raw `sub`가 audit 로그나 client 응답에 기록됨 | broker는 모든 audit 이벤트를 자체 allowlist로 projection해 `subjectHash`/`issuerHash`/`sessionIdHash`(keyed-HMAC) + `provider`/`reasonCode`/timestamp만 방출; core `FORBIDDEN_KEYS`를 broker token/claim key까지 확장; access token은 **폐기**(저장·사용 안 함). 실질적 잔여 없음 |
67
66
  | KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | `haechi-crypto-kms` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은 엔드포인트에 도달 | optional-peer + injected-client 모델과 **faithful-mock conformance**(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation); Vault `fetch`는 위 satellite-local SSRF 가드 수행; 모든 backend는 provider 오류를 generic fail-closed 오류로 매핑하고 provider/key-ARN 상세를 audit에 기록하지 않음. live-backend 검증은 CI 외부 |
68
- | 동적 로딩된 악의적/침해된 signed plugin (1.0) | signed `authProvider` plugin이 worker sandbox에 로딩된 뒤 실행 중 host를 악용 | `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519 서명, **trust-anchor-only** 키 해석(`signerKeyId`가 allowlist된 anchor가 아니면 verify 이전 거부; 알고리즘은 Ed25519로 고정), pin + `pluginId`별 version-floor + revocation denylist(`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window 집행, `assertAuthProviderConformance` 정합성 게이트, `node:worker_threads` memory/crash 격리 + per-call timeout-terminate, 전체 lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`). 전체 게이트는 매 respawn마다 재실행. **수용된 잔여:** signed plugin 자신의 `fs`/`fetch`/`process.env`는 차단되지 않으며(`networkEgress: false`는 선언일 뿐 1.0에서 집행 통제 아님) 정당하게 받은 credential을 exfiltrate할 수 있음 — 오직 signing/vetting 신뢰 모델로만 통제됨. **1.1이 새 opt-in `process-isolated` 런타임에 대해 이 잔여를 닫는다**(다음 행, P1-SEC-027); `worker_threads`(1.0) 모드는 불변이며 이 수용된 잔여를 유지 |
67
+ | 동적 로딩된 악의적/침해된 signed plugin (1.0) | signed `authProvider` plugin이 worker sandbox에 로딩된 뒤 실행 중 host를 악용 | `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519 서명, **trust-anchor-only** 키 해석(`signerKeyId`가 allowlist된 anchor가 아니면 verify 이전 거부; 알고리즘은 Ed25519로 고정), pin + `pluginId`별 version-floor + revocation denylist(`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window 집행, `assertAuthProviderConformance` 정합성 게이트, `node:worker_threads` memory/crash 격리 + per-call timeout-terminate, 전체 lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`). 전체 게이트는 매 respawn마다 재실행. **수용된 잔여:** signed plugin 자신의 `fs`/`fetch`/`process.env`는 차단되지 않으며(`networkEgress: false`는 선언일 뿐 1.0에서 집행 통제 아님) 정당하게 받은 credential을 exfiltrate할 수 있음 — 오직 signing/vetting 신뢰 모델로만 통제됨. **1.1이 새 opt-in `process-isolated` 런타임에 대해 이 잔여를 닫음**(다음 행, P1-SEC-027); `worker_threads`(1.0) 모드는 불변이며 이 수용된 잔여를 유지 |
69
68
  | plugin으로의 PII/secret leak (1.0) | request body·crypto 키·token vault·raw claim이 worker 경계를 넘어 유출 | host는 worker에 **credential slice만** 전달(`Authorization` 헤더 / bearer token — request body 절대 안 보냄, crypto 키 절대 안 보냄); wire는 MessagePort 위 평문 JSON 문자열; **null-prototype, own-key-allowlist claims sanitizer**가 `__proto__`/`constructor`/`prototype`을 제거하고 크기를 bound한 뒤 **host**가 `buildExternalIdentity`로 keyed-HMAC identity를 구성(HMAC 키는 worker에 들어가지 않음). **수용된 잔여:** auth plugin이 정당하게 검증하는 credential은 그 plugin에 보임(위 행 참조) |
70
69
  | 경계 간 object/proto smuggling (1.0) | 악의적 claims object가 host prototype을 오염시키거나 raw 값을 경계 너머로 밀반입 | JSON-string wire만 사용(structured-clone 없음, `SharedArrayBuffer`/transferables 없음 → shared-memory·object-graph 채널 없음) + `buildExternalIdentity` 이전 null-proto own-key-allowlist sanitizer. 실질적 잔여 없음 |
71
70
  | plugin entry의 swap / TOCTOU (1.0) | 서명 검사 후 실행 전에 검증된 entry 바이트가 swap됨(예: symlink 경로 재해석) | 서명이 `entrySha256`을 바인딩; loader는 entry를 **메모리로** 읽어 hash·verify하고 **메모리 내 검증된 소스에서** Worker를 spawn(`eval: true`)하며 검증 후 경로를 재해석하지 않고 symlink entry를 거부. 실질적 잔여 없음 |
@@ -77,9 +76,9 @@ Haechi가 보호하려는 주요 자산은 다음이다.
77
76
 
78
77
  ## 4. 명시적 제외
79
78
 
80
- Haechi는 다음을 보장하지 않는다.
79
+ Haechi는 다음을 보장하지 않습니다.
81
80
 
82
- - 코어 자체의 운영 KMS/HSM/Vault adapter 제공(`haechi-crypto-kms` satellite가 외부 `cryptoProvider` 계약을 통해 AWS/GCP/Azure/Vault adapter를 제공한다)
81
+ - 코어 자체의 운영 KMS/HSM/Vault adapter 제공(`haechi-crypto-kms` satellite가 외부 `cryptoProvider` 계약을 통해 AWS/GCP/Azure/Vault adapter를 제공합니다)
83
82
  - internet-facing gateway 인증/인가
84
83
  - `streaming.maxMatchBytes`보다 긴 cross-frame 매칭(스트림 프레임에 걸쳐 분할될 수 있음)
85
84
  - `block`이 발동되기 전에 이미 방출된 스트림 바이트의 회수
@@ -89,7 +88,7 @@ Haechi는 다음을 보장하지 않는다.
89
88
  - 외부 MCP server의 OAuth/resource binding 검증
90
89
  - base64/URL-encoded 값, 유니코드 난독화 값의 디코딩 후 검사
91
90
  - URL query string 내 민감값 검사 (JSON body만 검사)
92
- - 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지한다; 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외된다
91
+ - 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지합니다. 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외됩니다
93
92
  - JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
94
93
  - `haechi-auth-oidc`의 multi-origin / CDN-fronted IdP(issuer host ≠ `token_endpoint`/`jwks_uri` host) — single-origin만 지원, `haechi-auth-jwt`와 동일 제약 (0.9)
95
94
  - refresh-token rotation / silent renewal / 장수명 broker 세션 — 0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
@@ -108,4 +107,4 @@ Haechi는 다음을 보장하지 않는다.
108
107
 
109
108
  ## 5. 남은 운영 전제
110
109
 
111
- 운영 사용자는 Haechi 외부에서 네트워크 접근 제어, upstream 인증, secret injection, key custody, 로그 보존, DSAR/삭제 요청 처리, 법적 transfer 근거를 책임져야 한다.
110
+ 운영 사용자는 Haechi 외부에서 네트워크 접근 제어, upstream 인증, secret injection, key custody, 로그 보존, DSAR/삭제 요청 처리, 법적 transfer 근거를 책임져야 합니다.
@@ -1,8 +1,7 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - Status: Draft 0.1
3
+ - Status: Living document (tracks core 1.1.x)
4
4
  - Date: 2026-06-10
5
- - Target version: 1.0.0
6
5
 
7
6
  ## 1. Assets Under Protection
8
7
 
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "proxy": {
9
9
  "host": "127.0.0.1",
10
- "port": 1016
10
+ "port": 11016
11
11
  },
12
12
  "responseProtection": {
13
13
  "enabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -68,6 +68,7 @@
68
68
  "check:types": "tsc -p jsconfig.json --noEmit",
69
69
  "pack:dry": "npm pack --dry-run",
70
70
  "scan:stale-names": "node scripts/stale-name-scan.mjs",
71
+ "scan:doc-freshness": "node scripts/check-doc-freshness.mjs",
71
72
  "check:packaging": "node scripts/check-core-packaging.mjs",
72
73
  "check:satellite-packaging": "node scripts/check-satellite-packaging.mjs",
73
74
  "sbom": "node scripts/generate-sbom.mjs",
@@ -92,7 +92,7 @@ async function initCommand(argv) {
92
92
  mode: result.config.mode,
93
93
  warnings: [
94
94
  "The generated .haechi/dev.keys.json file is for local development only.",
95
- "Haechi 0.3.x does not include a production KMS/HSM/Vault key provider."
95
+ "Core ships no production KMS/HSM/Vault key provider; KMS/Vault-backed custody is available via the haechi-crypto-kms satellite (external cryptoProvider)."
96
96
  ]
97
97
  });
98
98
  }
@@ -56,6 +56,7 @@ export function defaultConfig() {
56
56
  },
57
57
  limits: {
58
58
  maxRequestBytes: 1048576,
59
+ maxNestingDepth: 256,
59
60
  upstreamTimeoutMs: 120000
60
61
  },
61
62
  policy: {
@@ -206,7 +207,10 @@ export function createRuntime(config, providers = {}) {
206
207
  policyEngine,
207
208
  cryptoProvider,
208
209
  tokenVault,
209
- auditSink
210
+ auditSink,
211
+ // Bound recursion depth so a deeply-nested payload fails closed (4xx)
212
+ // rather than overflowing the stack (uncaught 500).
213
+ limits: { maxNestingDepth: normalized.limits.maxNestingDepth }
210
214
  })
211
215
  };
212
216
  }
@@ -306,7 +310,7 @@ export function normalizeConfig(config) {
306
310
  throw new Error("audit.anchor.everyRecords must be a positive integer");
307
311
  }
308
312
  if (merged.tokenVault.provider !== "local") {
309
- throw new Error("0.2 only supports local token vault provider");
313
+ throw new Error("Only the local token vault provider is supported");
310
314
  }
311
315
  if (!["disabled", "local-dev"].includes(merged.tokenVault.revealPolicy)) {
312
316
  throw new Error(`Invalid tokenVault.revealPolicy: ${merged.tokenVault.revealPolicy}`);
@@ -362,6 +366,9 @@ export function normalizeConfig(config) {
362
366
  if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
363
367
  throw new Error("limits.maxRequestBytes must be a positive number");
364
368
  }
369
+ if (!Number.isInteger(merged.limits.maxNestingDepth) || merged.limits.maxNestingDepth < 1) {
370
+ throw new Error("limits.maxNestingDepth must be a positive integer");
371
+ }
365
372
  if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
366
373
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
367
374
  }
@@ -2,11 +2,24 @@ import { createHash, randomUUID } from "node:crypto";
2
2
 
3
3
  const NO_ENFORCE_MODES = new Set(["dry-run", "report-only"]);
4
4
 
5
- export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run" }) {
5
+ // Safe built-in ceiling on JSON nesting depth. collectStringEntries walks the
6
+ // tree recursively, so an attacker-shaped deeply-nested payload (within
7
+ // limits.maxRequestBytes) would otherwise overflow the call stack and crash the
8
+ // process uncaught. This default protects direct callers of the exported
9
+ // collectStringEntries; the proxy path threads the configurable
10
+ // limits.maxNestingDepth through createHaechi → protectJson instead.
11
+ export const DEFAULT_MAX_NESTING_DEPTH = 256;
12
+
13
+ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run", limits = {} }) {
6
14
  if (!filterEngine || !policyEngine || !cryptoProvider || !auditSink) {
7
15
  throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
8
16
  }
9
17
 
18
+ // Resolve once at construction; protectJson and the stream protector reuse it.
19
+ const maxNestingDepth = Number.isInteger(limits.maxNestingDepth) && limits.maxNestingDepth > 0
20
+ ? limits.maxNestingDepth
21
+ : DEFAULT_MAX_NESTING_DEPTH;
22
+
10
23
  async function protectJson(payload, rawContext = {}) {
11
24
  // A per-request policy engine (a named profile selected from identity)
12
25
  // overrides the default. It is a control object, NOT data: strip it before
@@ -14,7 +27,10 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
14
27
  const { policyEngine: contextEngine, ...context } = rawContext;
15
28
  const effectiveMode = context.mode ?? mode;
16
29
  const engine = contextEngine ?? policyEngine;
17
- const entries = collectStringEntries(payload);
30
+ // Fail closed on an over-deep payload BEFORE any detection/transform work,
31
+ // mirroring the byte-limit path: the thrown error carries statusCode 413 so
32
+ // the proxy surfaces a clean 4xx rather than a stack-overflow 500.
33
+ const entries = collectStringEntries(payload, [], { maxDepth: maxNestingDepth });
18
34
  // `context` is threaded into detection as-is and is LOAD-BEARING: e.g.
19
35
  // `context.direction` ("request" | "response") gates direction-scoped rules
20
36
  // (injection) and the response-only marker exclusion in the filter engine.
@@ -97,7 +113,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
97
113
  // Transform a complete, committed text segment.
98
114
  async function transformSegment(text) {
99
115
  const detections = await filterEngine.detect({
100
- entries: collectStringEntries(text),
116
+ entries: collectStringEntries(text, [], { maxDepth: maxNestingDepth }),
101
117
  context
102
118
  });
103
119
  const decisions = await decideAll(detections);
@@ -119,7 +135,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
119
135
  // delta text (e.g. tool-call arguments). Returns the mutated object.
120
136
  async protectFrameExtras(value) {
121
137
  const detections = await filterEngine.detect({
122
- entries: collectStringEntries(value),
138
+ entries: collectStringEntries(value, [], { maxDepth: maxNestingDepth }),
123
139
  context
124
140
  });
125
141
  if (detections.length === 0) {
@@ -143,7 +159,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
143
159
  async push(text) {
144
160
  pending += text;
145
161
  const detections = await filterEngine.detect({
146
- entries: collectStringEntries(pending),
162
+ entries: collectStringEntries(pending, [], { maxDepth: maxNestingDepth }),
147
163
  context
148
164
  });
149
165
  let commit = Math.max(0, pending.length - maxMatchBytes);
@@ -176,7 +192,14 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
176
192
  return { protectJson, createStreamProtector };
177
193
  }
178
194
 
179
- export function collectStringEntries(value, path = []) {
195
+ export function collectStringEntries(value, path = [], options = {}) {
196
+ // `options.maxDepth` bounds recursion to fail closed on a deeply-nested
197
+ // payload (which would otherwise overflow the call stack → uncaught crash).
198
+ // Additive third arg: existing 2-arg callers get DEFAULT_MAX_NESTING_DEPTH.
199
+ const maxDepth = Number.isInteger(options.maxDepth) && options.maxDepth > 0
200
+ ? options.maxDepth
201
+ : DEFAULT_MAX_NESTING_DEPTH;
202
+
180
203
  if (typeof value === "string") {
181
204
  return [{ path, pathText: safePathToString(path), value, kind: "value" }];
182
205
  }
@@ -187,8 +210,15 @@ export function collectStringEntries(value, path = []) {
187
210
  return [{ path, pathText: safePathToString(path), value: String(value), kind: "number" }];
188
211
  }
189
212
 
213
+ // Descending into an array/object would exceed the configured depth. Throw a
214
+ // fail-closed error carrying statusCode 413 (mirroring the byte-limit path) so
215
+ // the proxy returns a clean 4xx instead of a stack-overflow 500.
216
+ if ((Array.isArray(value) || (value && typeof value === "object")) && path.length >= maxDepth) {
217
+ throw nestingDepthError(maxDepth);
218
+ }
219
+
190
220
  if (Array.isArray(value)) {
191
- return value.flatMap((item, index) => collectStringEntries(item, path.concat(index)));
221
+ return value.flatMap((item, index) => collectStringEntries(item, path.concat(index), { maxDepth }));
192
222
  }
193
223
 
194
224
  if (value && typeof value === "object") {
@@ -196,13 +226,22 @@ export function collectStringEntries(value, path = []) {
196
226
  // otherwise be forwarded upstream in plaintext.
197
227
  return Object.entries(value).flatMap(([key, item]) => [
198
228
  { path: path.concat(key), pathText: safePathToString(path.concat(key)), value: key, kind: "key" },
199
- ...collectStringEntries(item, path.concat(key))
229
+ ...collectStringEntries(item, path.concat(key), { maxDepth })
200
230
  ]);
201
231
  }
202
232
 
203
233
  return [];
204
234
  }
205
235
 
236
+ function nestingDepthError(maxDepth) {
237
+ const error = new Error(`Request JSON nesting exceeds limits.maxNestingDepth (${maxDepth})`);
238
+ // statusCode/errorCode let the proxy catch-all surface this as a clean 4xx,
239
+ // exactly like the request-body-too-large guard in the proxy body reader.
240
+ error.statusCode = 413;
241
+ error.errorCode = "haechi_request_too_deeply_nested";
242
+ return error;
243
+ }
244
+
206
245
  export function pathToString(path) {
207
246
  return path.reduce((text, part, index) => {
208
247
  if (typeof part === "number") {
@@ -1,8 +1,9 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import { isUtf8 } from "node:buffer";
3
4
  import { inspectResponseStream } from "../stream-filter/index.mjs";
4
5
 
5
- export const DEFAULT_PROXY_PORT = 1016;
6
+ export const DEFAULT_PROXY_PORT = 11016;
6
7
 
7
8
  export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
8
9
  assertSafeProxyBind({ host, allowRemoteBind });
@@ -569,9 +570,23 @@ function readBody(request, { maxBytes }) {
569
570
  chunks.push(chunk);
570
571
  });
571
572
  request.on("end", () => {
572
- if (!rejected) {
573
- resolve(Buffer.concat(chunks).toString("utf8"));
573
+ if (rejected) {
574
+ return;
575
+ }
576
+ const raw = Buffer.concat(chunks);
577
+ // Fail closed on a non-UTF-8 body: Buffer.toString("utf8") would otherwise
578
+ // replace invalid bytes with U+FFFD BEFORE detection runs, so a secret/PII
579
+ // could be smuggled past the regex rules via invalid encoding. Reject with
580
+ // a clear 4xx instead of lossily decoding.
581
+ if (raw.byteLength > 0 && !isUtf8(raw)) {
582
+ reject(proxyError({
583
+ statusCode: 400,
584
+ errorCode: "haechi_request_body_not_utf8",
585
+ message: "Request body is not valid UTF-8"
586
+ }));
587
+ return;
574
588
  }
589
+ resolve(raw.toString("utf8"));
575
590
  });
576
591
  request.on("error", (error) => {
577
592
  if (!rejected) {