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.
- package/README.ko.md +101 -97
- package/README.md +10 -6
- package/SECURITY.md +12 -10
- package/docs/current/api-stability.ko.md +26 -26
- package/docs/current/configuration.ko.md +102 -102
- package/docs/current/configuration.md +6 -6
- package/docs/current/open-source-modular-architecture.ko.md +1 -1
- package/docs/current/open-source-modular-architecture.md +1 -1
- package/docs/current/release-process.ko.md +19 -20
- package/docs/current/release-process.md +1 -2
- package/docs/current/reliability-hardening-track.ko.md +77 -0
- package/docs/current/reliability-hardening-track.md +77 -0
- package/docs/current/risk-register-release-gate.ko.md +25 -27
- package/docs/current/risk-register-release-gate.md +18 -20
- package/docs/current/shared-responsibility.ko.md +33 -24
- package/docs/current/shared-responsibility.md +12 -3
- package/docs/current/threat-model.ko.md +10 -11
- package/docs/current/threat-model.md +1 -2
- package/haechi.config.example.json +1 -1
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +1 -1
- package/packages/cli/runtime.mjs +9 -2
- package/packages/core/index.mjs +47 -8
- package/packages/proxy/index.mjs +18 -3
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Haechi Shared Responsibility
|
|
2
2
|
|
|
3
|
-
- Status:
|
|
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
|
|
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
|
-
- 문서 상태:
|
|
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
|
|
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
|
|
38
|
-
| Ollama 암묵 streaming 우회 | `stream` 생략 시 NDJSON 평문 유출 | `/api/chat`·`/api/generate`는 `stream: false
|
|
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` 런타임에 대해 이 잔여를
|
|
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까지의 레코드 삭제를
|
|
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 근거를 책임져야 합니다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "1.1.
|
|
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
|
-
"
|
|
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
|
}
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -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("
|
|
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
|
}
|
package/packages/core/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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") {
|
package/packages/proxy/index.mjs
CHANGED
|
@@ -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 =
|
|
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 (
|
|
573
|
-
|
|
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) {
|