haechi 1.3.1 → 1.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/README.ko.md +3 -3
- package/README.md +3 -3
- package/docs/current/code-review-risk-register-2026-06-16-round2.ko.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16-round2.md +142 -0
- package/docs/current/operations-runbook.ko.md +21 -1
- package/docs/current/operations-runbook.md +22 -1
- package/docs/current/release-process.ko.md +14 -6
- package/docs/current/release-process.md +14 -6
- package/docs/current/risk-register-release-gate.ko.md +22 -4
- package/docs/current/risk-register-release-gate.md +22 -4
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +1 -1
- package/packages/cli/runtime.mjs +5 -1
- package/packages/plugin/process-sandbox.mjs +56 -1
- package/packages/plugin/sandbox.mjs +23 -0
- package/packages/proxy/index.mjs +128 -12
- package/packages/token-vault/index.mjs +46 -5
package/README.ko.md
CHANGED
|
@@ -90,7 +90,7 @@ node packages/cli/bin/haechi.mjs proxy --config haechi.config.json
|
|
|
90
90
|
|
|
91
91
|
proxy는 기본적으로 loopback에 바인딩됩니다. `0.0.0.0`, `::`, 또는 그 밖의 non-loopback 호스트에 바인딩하려면 `--allow-remote-bind`를 명시적으로 전달해야 합니다. 이 플래그는 명시적인 네트워크 접근 통제가 있을 때만 사용하세요.
|
|
92
92
|
|
|
93
|
-
`stream: true`인 스트리밍 요청은 기본적으로 차단됩니다. `streaming.requestMode`를 `inspect`로 설정하면 SSE/NDJSON 응답을 stream-filter합니다(bounded sliding buffer가 프레임에 걸쳐 나뉜 PII도
|
|
93
|
+
`stream: true`인 스트리밍 요청은 기본적으로 차단됩니다. `streaming.requestMode`를 `inspect`로 설정하면 SSE/NDJSON 응답을 stream-filter합니다(JSON **델타 채널**을 훑는 bounded sliding buffer가 델타 프레임에 걸쳐 나뉜 PII도 `streaming.maxMatchBytes`까지 잡아냅니다. 델타가 아닌 leaf와 비JSON 프레임은 각 프레임 내에서 검사됩니다). 호출자가 보호되지 않는 스트리밍을 명시적으로 감수하는 경우에만 `pass-through`로 설정하세요.
|
|
94
94
|
|
|
95
95
|
Ollama의 `/api/chat`과 `/api/generate`는 `stream` 필드가 없으면 기본적으로 스트리밍하므로, proxy는 `stream: false`가 명시되지 않는 한 이 요청들을 스트리밍으로 간주합니다.
|
|
96
96
|
|
|
@@ -153,7 +153,7 @@ stdio MCP 서버를 감싸 양방향 트래픽을 필터링합니다. MCP 클라
|
|
|
153
153
|
}
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
클라이언트→서버 요청은 `mcp.allowedMethods` allowlist와 params 보호를 거치고, 서버→클라이언트 결과는 params/result 보호와 injection 휴리스틱(아래 참고)을 적용받습니다. 거부된 요청은 클라이언트에 응답되고 서버에는 도달하지 않습니다.
|
|
156
|
+
클라이언트→서버 요청은 `mcp.allowedMethods` allowlist와 params 보호를 거치고, 서버→클라이언트 결과는 params/result 보호와 injection 휴리스틱(아래 참고)을 적용받습니다. 거부된 요청은 클라이언트에 응답되고 서버에는 도달하지 않습니다. exit code는 그대로 전달되며, 자식의 stderr은 기본적으로 줄 단위로 동일한 보호를 거쳐 **필터링됩니다**(`--stderr filter`) — 원시 전달은 `--stderr inherit`을, 폐기는 `--stderr drop`을 사용하세요(줄 단위 필터는 자식이 개행을 넘어 쪼갠 비밀을 잡지 못하므로, 고민감 도구에는 폐기를 권장합니다). `filter`는 `policy.mode: enforce`에서만 변환합니다.
|
|
157
157
|
|
|
158
158
|
## Injection Detection (Preview)
|
|
159
159
|
|
|
@@ -322,7 +322,7 @@ Haechi는 로컬 정책 부트스트래핑을 위한 지역별 기본 Privacy Pr
|
|
|
322
322
|
|
|
323
323
|
0.4.0은 token round-trip(deterministic tokenization + 요청 스코프 응답 detokenization), `mcp-wrap` 양방향 MCP 필터, `status` 및 `audit-verify` 커맨드, report-only injection detection 휴리스틱을 추가하고, 0.6 인증을 위한 PII-safe `identity`/`authProvider` 계약을 예약합니다. `docs/current/release-0.4-implementation-scope.md` 참고.
|
|
324
324
|
|
|
325
|
-
0.5.0은 SSE/NDJSON 스트리밍 응답 검사를 추가합니다. `streaming.requestMode: "inspect"`가 bounded sliding buffer로 응답을 stream-filter하여 프레임에 걸쳐 나뉜 PII도 잡아냅니다(`streaming.maxMatchBytes`). `docs/current/release-0.5-implementation-scope.md` 참고.
|
|
325
|
+
0.5.0은 SSE/NDJSON 스트리밍 응답 검사를 추가합니다. `streaming.requestMode: "inspect"`가 JSON **델타 채널**을 훑는 bounded sliding buffer로 응답을 stream-filter하여 델타 프레임에 걸쳐 나뉜 PII도 잡아냅니다(`streaming.maxMatchBytes`). 델타가 아닌 leaf와 비JSON 콘텐츠 프레임은 각 프레임 내에서 검사됩니다. `docs/current/release-0.5-implementation-scope.md` 참고.
|
|
326
326
|
|
|
327
327
|
0.6.0은 인증과 클라이언트별 통제를 추가합니다. 해시 기반 token 저장소와 `haechi auth` CLI를 갖춘 내장 bearer auth, identity scope/label로 바인딩되는 named policy profile, model allowlisting, identity별 rate limiting을 제공하며, audit 로그에는 PII-safe identity가 기록됩니다. `docs/current/release-0.6-implementation-scope.md` 참고.
|
|
328
328
|
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ Point an existing HTTP JSON client at `http://localhost:11016` and set `target.u
|
|
|
90
90
|
|
|
91
91
|
The proxy binds to loopback by default. Binding to `0.0.0.0`, `::`, or another non-loopback host fails unless `--allow-remote-bind` is provided. Use that flag only behind explicit network access controls.
|
|
92
92
|
|
|
93
|
-
Streaming requests with `stream: true` are blocked by default. Set `streaming.requestMode` to `inspect` to stream-filter SSE/NDJSON responses (a bounded sliding buffer catches PII split across frames
|
|
93
|
+
Streaming requests with `stream: true` are blocked by default. Set `streaming.requestMode` to `inspect` to stream-filter SSE/NDJSON responses (a bounded sliding buffer over the JSON **delta channel** catches PII split across delta frames, up to `streaming.maxMatchBytes`; non-delta leaves and non-JSON frames are inspected within each frame), or to `pass-through` only when the caller explicitly accepts unprotected streaming.
|
|
94
94
|
|
|
95
95
|
Ollama `/api/chat` and `/api/generate` stream by default when the `stream` field is omitted, so the proxy treats those requests as streaming unless `stream: false` is explicitly set.
|
|
96
96
|
|
|
@@ -153,7 +153,7 @@ Wrap any stdio MCP server so its traffic is filtered in both directions — chan
|
|
|
153
153
|
}
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
Client→server requests pass the `mcp.allowedMethods` allowlist and params protection; server→client results get params/result protection plus injection heuristics (see below). Rejections are answered to the client and never reach the server; stderr
|
|
156
|
+
Client→server requests pass the `mcp.allowedMethods` allowlist and params protection; server→client results get params/result protection plus injection heuristics (see below). Rejections are answered to the client and never reach the server. Exit codes pass through; the child's stderr is **filtered** through the same protection per line by default (`--stderr filter`) — use `--stderr inherit` for raw passthrough or `--stderr drop` to discard (recommended for high-sensitivity tools, since a per-line filter cannot catch a secret a child splits across a newline). `filter` transforms only under `policy.mode: enforce`.
|
|
157
157
|
|
|
158
158
|
## Injection Detection (Preview)
|
|
159
159
|
|
|
@@ -322,7 +322,7 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
|
|
|
322
322
|
|
|
323
323
|
0.4.0 adds the token round-trip (deterministic tokenization + request-scoped response detokenization), the `mcp-wrap` bidirectional MCP filter, `status` and `audit-verify` commands, report-only injection detection heuristics, and reserves the PII-safe `identity`/`authProvider` contracts for 0.6 auth. See `docs/current/release-0.4-implementation-scope.md`.
|
|
324
324
|
|
|
325
|
-
0.5.0 adds SSE/NDJSON streaming response inspection: `streaming.requestMode: "inspect"` stream-filters responses with a bounded sliding buffer that catches PII split across frames (`streaming.maxMatchBytes`). See `docs/current/release-0.5-implementation-scope.md`.
|
|
325
|
+
0.5.0 adds SSE/NDJSON streaming response inspection: `streaming.requestMode: "inspect"` stream-filters responses with a bounded sliding buffer over the JSON **delta channel** that catches PII split across delta frames (`streaming.maxMatchBytes`); non-delta leaves and non-JSON content frames are inspected within each frame. See `docs/current/release-0.5-implementation-scope.md`.
|
|
326
326
|
|
|
327
327
|
0.6.0 adds authentication and per-client controls: built-in bearer auth with a hashed token store and `haechi auth` CLI, named policy profiles bound by identity scope/label, model allowlisting, and per-identity rate limiting — with PII-safe identity in the audit log. See `docs/current/release-0.6-implementation-scope.md`.
|
|
328
328
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 2026-06-16 코드리뷰 리스크 등록부 — Round 2 (CR2)
|
|
2
|
+
|
|
3
|
+
상태: 보완 완료 및 `haechi@1.3.2`로 발행(P0/P1 없음; CR2-001..008 Resolved; CR2-009 won't-fix, CR2-010 accepted; G10 Pass, 2026-06-16)
|
|
4
|
+
범위: `main`의 `36af9fd1eef2b1e19b19b2e0344faab0a7a3e83d`(post-1.3.1)
|
|
5
|
+
검토일: 2026-06-16
|
|
6
|
+
출처: 1.3.1 보완 컷 이후 진행한 2차 심층 리뷰, 각 항목을 현재 코드에 대해 적대적으로 검증
|
|
7
|
+
|
|
8
|
+
이 문서는 `code-review-risk-register-2026-06-16.md`(round 1, 모두 Resolved이며 1.3.1로 발행)와 분리해 둔 **2차 라운드**다. round 1은 threat model과 코드가 인용하는 frozen resolution 기록이다. Round 2는 1.3.1 이후 제기되어 현재 트리에 대해 재검증된 항목을 담는다. 일부는 round-1 주장을 확장하거나 한정하므로, frozen 기록에 되쓰지 않고 상호 참조한다.
|
|
9
|
+
|
|
10
|
+
## 릴리스 판단
|
|
11
|
+
|
|
12
|
+
round-1 P0/P1은 모두 Resolved이며 `haechi@1.3.1`로 발행되었다. Round 2는 **P0도 P1도 발견하지 못했다**: 외부 리뷰에서 P1로 제기된 두 항목은 모두 검증 결과 **P2**로 내려갔다(둘 다 stored-plaintext leak도, auth/SSRF 우회도 아니다). 확인된 P2는 프록시 데이터 경로의 availability/resource leak, 호출자 제공 입력을 반영하는 audit-hygiene 공백, 그리고 무제한 plugin IPC reply다. 세 개의 P2와 P3 묶음(`CR2-001..008`)은 이제 **Resolved이며 `haechi@1.3.2`로 발행되었다**(2026-06-16, attested OIDC publish); `CR2-009`는 won't-fix(false positive), `CR2-010`은 accepted(문서화된 잔여 리스크)로 유지된다. **G10은 Pass다.** 운영자는 CR2 수정 사항을 반영하려면 `1.3.1`에서 `1.3.2`로 업그레이드해야 한다.
|
|
13
|
+
|
|
14
|
+
## 심각도 기준
|
|
15
|
+
|
|
16
|
+
- `P0`: 신뢰 경계를 넘어가는 직접적인 자격증명/데이터 유출, 또는 핵심 보안 약속을 깨는 우회.
|
|
17
|
+
- `P1`: SSRF, 보호 우회, 서비스 거부, 보호 배포를 깨뜨릴 수 있는 프로토콜 동작.
|
|
18
|
+
- `P2`: 넓은 채택 전 해결해야 하는 운영, 정확성, availability, hygiene 공백.
|
|
19
|
+
- `P3`: 영향이 작은 하드닝, 유한 경계 robustness, 또는 문서 정확성.
|
|
20
|
+
|
|
21
|
+
## 검증 노트
|
|
22
|
+
|
|
23
|
+
아래 모든 항목은 독립 리뷰어가 보고자 진술을 그대로 믿지 않고 현재 코드에 대해 추적했다. 보고된 P1 두 건은 검증 후 P2로 하향됐다(부풀림 없음). 보고된 한 항목은 **false positive**였고 한 항목은 **이미 문서화된 수용 잔여 리스크**였다 — 둘 다 audit trail을 위해 여기 기록하며 코드 변경은 필요 없다.
|
|
24
|
+
|
|
25
|
+
## 요약
|
|
26
|
+
|
|
27
|
+
| ID | 심각도 | 영역 | 리스크 | 상태 |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
| CR2-001 | P2 | 프록시 availability | pass-through streaming이 downstream 클라이언트 disconnect 시 upstream reader를 절대 취소하지 않는다 — `await once(response,"drain")`이 영원히 park되어 upstream connection/task가 leak되고, 인증되지 않은 클라이언트가 반복적으로 disconnect해 dangling upstream connection을 누적할 수 있다. | Resolved |
|
|
30
|
+
| CR2-002 | P2 | audit hygiene | token-vault reveal/purge 실패가 호출자 제공 raw `token`과 `error.message`(token을 interpolate함)를 audit event에 기록한다; `FORBIDDEN_KEYS`는 key 이름으로만 제거하므로, `tok_` id가 기대되는 자리에 secret을 넘기면 hash-chained 로그에 raw로 남는다. stored vault plaintext가 아니라 호출자 입력을 반영한다. | Resolved |
|
|
31
|
+
| CR2-003 | P2 | plugin sandbox DoS | `maxMessageBytes`는 host→plugin credential 메시지만 제한한다; plugin→host reply는 무제한으로 수신·`JSON.parse`된다. process-isolated child에 heap cap이 없어, 적대적/버그 있는 signed plugin이 oversized reply를 반환 → host의 동기 parse가 event loop를 정지시키고 메모리가 급증한다. | Resolved |
|
|
32
|
+
| CR2-004 | P3 | 프록시 헤더 | `sanitizeResponseHeaders`가 body가 변환됐을 때 body-coupled validator(`etag`/`content-md5`/`digest`/`last-modified`)를 유지해 stale 상태가 된다; 변경된 응답에 `cache-control: no-store`가 없다. | Resolved |
|
|
33
|
+
| CR2-005 | P3 | 프록시 robustness | `maxBytes`를 초과하는 body에 대해 `readBody`는 reject하지만 socket 읽기/teardown을 멈추지 않아, 업로드가 (유한한) Node `requestTimeout`까지 read-and-discard된다. | Resolved |
|
|
34
|
+
| CR2-006 | P3 | MCP wrap | `mcp-wrap --stderr filter`는 완성된 라인 단위로 보호하므로, 적대적 child가 의도적으로 newline에 걸쳐 분할한 secret은 anchored regex를 회피한다. 신뢰된 로컬 child의 진단 출력에 대한 라인 지향 필터링의 본질적 한계다; single-line secret은 잡히고 `--stderr drop`이 있다. 문서 전용. | Resolved |
|
|
35
|
+
| CR2-007 | P3 | Docs | README는 MCP wrap이 "stderr and exit codes pass through"라고 하지만, 기본값은 이제 `--stderr filter`다(round-1 P2-CR-006). | Resolved |
|
|
36
|
+
| CR2-008 | P3 | Docs | README의 streaming "split match" 주장이 범위 한정이 없다; cross-frame buffering은 JSON delta 채널에만 적용되며 임의의 non-JSON frame에는 적용되지 않는다. | Resolved |
|
|
37
|
+
| CR2-009 | — | plugin sandbox | (보고된 P2) `keyMaterial`이 base credential 메시지의 `maxMessageBytes` 검사 이후에 append된다. **FALSE POSITIVE:** `keyMaterial`은 운영자 통제이며 fetcher의 `maxBytes`로 hard-bound된다; 공격자 증폭 없음. 수정 불필요(선택적 cosmetic re-assert만). | Won't fix |
|
|
38
|
+
| CR2-010 | — | Streaming | (보고된 P2) 두 개의 NON-JSON SSE/NDJSON frame에 걸쳐 분할된 secret은 잡히지 않는다(per-frame 검사). **수용 잔여 리스크 — 이미 문서화됨**: round-1 P1-CR-005 resolution, `threat-model.md` exclusions, 그리고 in-code comment. 변경 없음. | Accepted |
|
|
39
|
+
|
|
40
|
+
## 상세 항목
|
|
41
|
+
|
|
42
|
+
### CR2-001: downstream disconnect 시 upstream reader 미취소
|
|
43
|
+
|
|
44
|
+
심각도: P2(가장 시급한 CR2 항목)
|
|
45
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
46
|
+
영향 코드: `packages/proxy/index.mjs`의 `pipeUpstreamBodyBounded` / `forward`
|
|
47
|
+
검증: pass-through streaming 경로에는 클라이언트 연결의 `close`/`aborted` listener가 없다; 클라이언트 socket이 죽은 뒤 `await once(response, "drain")`이 무기한 park된다(`drain`도 `error`도 발생하지 않음). 그래서 async task와 upstream connection이 leak된다. 전제 조건 없이 **인증되지 않은** 클라이언트가 도달 가능하다; 스트림 도중 반복 disconnect는 프록시와 그 upstream LLM 엔드포인트에 대한 dangling upstream connection을 누적한다.
|
|
48
|
+
|
|
49
|
+
해소: per-request `AbortController`를 `forward()`에 전달하고(upstream fetch를 abort) upstream reader를 취소하는 one-shot 클라이언트 `close`/`aborted` listener를 등록한다; `drain` 대기를 `close`와 race시켜 backpressure 대기가 disconnect 시 unpark되게 한다; no-backpressure `reader.read()` parked 케이스도 다룬다. 회귀 테스트: 스트림 도중 disconnect하고 reader가 즉시 취소되는지 / upstream이 abort되는지 단언.
|
|
50
|
+
|
|
51
|
+
### CR2-002: Token-Vault Reveal/Purge가 Raw Token + Error Text를 Audit에 기록
|
|
52
|
+
|
|
53
|
+
심각도: P2(보고된 P1에서 하향 — stored vault plaintext가 아니라 호출자 제공 입력을 반영함)
|
|
54
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #97)
|
|
55
|
+
영향 코드: `packages/token-vault/index.mjs`(reveal/purge throw + record), `packages/audit/index.mjs`(`FORBIDDEN_KEYS` / `sanitizeAudit`)
|
|
56
|
+
검증: reveal은 `Unknown token: ${token}` / `Token expired: ${token}`을 throw하고 catch가 `reason: error.message`를 기록한다; raw `token` 인자도 `reveal_failed`/`reveal_denied`/`purge`에 그대로 기록된다. `sanitizeAudit`는 key 이름으로 필터링하고 `FORBIDDEN_KEYS`는 `reason`도 `token`도 포함하지 않으므로, 둘 다 기록된 hash-chained 레코드로 살아남는다. 정상 흐름에서 `token` 인자는 비민감 `tok_<type>_<hash>` id이므로, 누출은 호출자/운영자가 token id가 기대되는 자리에 raw secret을 넘길 때만 발생한다 — 그래도 round-1 `P1-SEC-017`과 `threat-model.md`의 "no plaintext / keyed-HMAC only" 표현과 모순된다.
|
|
57
|
+
|
|
58
|
+
해소: (1) 일반화된 오류 메시지(raw token interpolation 없음); (2) raw `token`을 그대로 기록하는 것을 중단 — reveal/purge 레코드 이전에 (`subjectHash`/`issuerHash`처럼) keyed-HMAC하거나 인자를 `tok_<type>_<hash>` 형태에 대해 검증하고 아니면 redact; (3) free-text `reason: error.message`를 enum `reasonCode`로 교체; (4) `reveal_failed`/`purge` event가 `reason`/`token`에 호출자 제공 raw token을 절대 포함하지 않는다는 회귀 테스트; 문서의 불변식 표현을 정합화.
|
|
59
|
+
|
|
60
|
+
### CR2-003: Plugin IPC Reply가 Size-Bound되지 않음; Process Child에 Heap Cap 없음
|
|
61
|
+
|
|
62
|
+
심각도: P2
|
|
63
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #98)
|
|
64
|
+
영향 코드: `packages/plugin/sandbox.mjs`, `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
65
|
+
검증: `maxMessageBytes`는 outbound host→plugin credential 메시지에만 강제된다; inbound reply는 두 sandbox 모두에서 size 검사 없이 수신·`JSON.parse`된다. worker에는 암묵적 경계가 있지만(필수 `resourceLimits` heap cap이 폭주 worker를 먼저 OOM시킴), process child는 `--max-old-space-size`를 설정하지 않으므로, 적대적/버그 있는 signed plugin이 child의 기본 V8 heap까지 reply를 만들어 `process.send`할 수 있고, host의 동기 `JSON.parse`가 event loop를 정지시킨다(per-call timeout이 parse 도중 발생할 수 없음). signed/semi-trusted-but-hostile plugin이 필요하다.
|
|
66
|
+
|
|
67
|
+
해소: 두 sandbox 모두에서 parse 이전에 reply를 경계화한다(worker/child `message` 핸들러에서 byte length를 `maxMessageBytes` 또는 전용 `maxReplyBytes`와 대조하고, oversized를 `JSON.parse` 이전에 deny로 drop); 새 `resourceLimits`/`processMaxOldGenerationSizeMb` knob에서 파생한 `--max-old-space-size`로 process child에 heap cap을 부여한다. 회귀 테스트: oversized claims 객체를 반환하는 fixture plugin → 무제한 host 작업 없이 deny. 1.0/1.1 scope 문서에 경계가 BOTH 방향에 적용됨을 명시.
|
|
68
|
+
|
|
69
|
+
### CR2-004: 변환된 응답의 Stale Body-Coupled Validator 헤더
|
|
70
|
+
|
|
71
|
+
심각도: P3
|
|
72
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
73
|
+
영향 코드: `packages/proxy/index.mjs`의 `sanitizeResponseHeaders` / `transformedJsonHeaders`
|
|
74
|
+
검증: hop-by-hop 헤더만 제거된다; `protectJson`이 body를 변경·재직렬화할 때 upstream `etag`/`content-md5`/`digest`/`last-modified`가 그대로 살아남고 `cache-control: no-store`가 설정되지 않는다. 문서화된 inference-upstream 타깃 집합(POST 응답, strong validator 없음, RFC 9111상 기본 비캐시; `content-length`는 재계산됨)에서는 실세계 영향이 작지만, 수용 잔여 리스크로 기록되어 있지 않다.
|
|
75
|
+
|
|
76
|
+
해소: 모든 body-mutating 경로의 drop 집합에 `etag`/`content-md5`/`digest`/`last-modified`를 추가한다; 변환된 응답에 `cache-control: no-store`를 설정한다. 테스트: 변경된 응답이 upstream `ETag`를 더 이상 담지 않음.
|
|
77
|
+
|
|
78
|
+
### CR2-005: 한도 초과 Request Body가 Drain/Teardown되지 않음
|
|
79
|
+
|
|
80
|
+
심각도: P3
|
|
81
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
82
|
+
영향 코드: `packages/proxy/index.mjs`의 `readBody`
|
|
83
|
+
검증: `maxBytes` 초과 시 `readBody`는 플래그를 세우고 reject하지만 request를 `pause()`/`destroy()`하지 않으며, 413 응답이 `Connection: close`를 보내지 않으므로 Node가 built-in `requestTimeout`(Node ≥22 기본 300000 ms)까지 업로드의 나머지를 read-and-discard한다. hold는 유한하다; `maxInFlight: 0`(기본값)은 동시에 hold되는 connection 수를 경계화하지 않는다.
|
|
84
|
+
|
|
85
|
+
해소: 413 시 `request.pause()`/`request.destroy()`(또는 응답 이전에 `Connection: close`)로 socket을 즉시 해제한다. 낮은 우선순위: non-null 기본 `requestTimeoutMs`/`headersTimeoutMs`를 출하하고 `maxInFlight: 0`이 동시성을 무제한으로 둔다는 점을 문서화.
|
|
86
|
+
|
|
87
|
+
### CR2-006: mcp-wrap `--stderr filter`가 Newline-Split Secret을 잡지 못함
|
|
88
|
+
|
|
89
|
+
심각도: P3(doc)
|
|
90
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
91
|
+
영향 코드: `packages/cli/bin/haechi.mjs`의 `pipeFilteredStderr` / `protectStderrLine`
|
|
92
|
+
검증: `filter`는 child stderr를 `\n`으로 분할하고 매 완성된 라인을 fresh single-shot protector로 보호하므로, 적대적 child가 의도적으로 newline에 걸쳐 분할 방출한 secret은 anchored full-secret regex를 회피한다. 좁은 범위: child는 운영자의 신뢰된 로컬 MCP server이고, single-line secret은 잡히며, `--stderr drop`이 있다. 이것은 라인 지향 텍스트 필터링의 본질적 속성이지 request/response 보호 경로의 익스플로잇 가능한 우회가 아니다.
|
|
93
|
+
|
|
94
|
+
해소(doc): `COMMAND_HELP`와 이 등록부에 `filter`가 완성된 라인 단위로 보호하며 newline에 걸쳐 분할된 secret을 잡지 못한다는 한 문장을 명시; 고민감 도구에는 `--stderr drop`을 권장. 선택적 후속 코드 하드닝: stderr를 per-line `protectText` 대신 push/flush sliding-buffer 채널(`maxMatchBytes`)로 라우팅.
|
|
95
|
+
|
|
96
|
+
### CR2-007: README mcp-wrap stderr Passthrough가 Stale
|
|
97
|
+
|
|
98
|
+
심각도: P3(doc)
|
|
99
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
100
|
+
영향 코드: `README.md`
|
|
101
|
+
검증: README는 "stderr and exit codes pass through"라고 하지만, 기본값은 이제 `--stderr filter`다(round-1 P2-CR-006); raw passthrough는 opt-in `inherit` 모드뿐이다. exit code는 실제로 pass through되므로 stderr 절만 stale하다; `COMMAND_HELP`는 이미 정확하다.
|
|
102
|
+
|
|
103
|
+
해소(doc): README 줄을 `filter` 기본값을 반영하도록 수정(`inherit`은 raw, `drop`은 폐기; `filter`는 `policy.mode: enforce`에서만 변환); `README.ko.md` sibling 갱신.
|
|
104
|
+
|
|
105
|
+
### CR2-008: README Streaming Split-Match 주장이 범위 한정 없음
|
|
106
|
+
|
|
107
|
+
심각도: P3(doc)
|
|
108
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
109
|
+
영향 코드: `README.md`
|
|
110
|
+
검증: README는 frame에 걸쳐 분할된 PII가 잡힌다고 주장하면서 이를 JSON delta 채널로 한정하지 않는다; non-JSON CONTENT frame은 single-shot per-frame `protectText`를 받는다(cross-frame buffer 없음). 이 주장은 `threat-model.md`와 scope 문서 대비 보장을 과장한다.
|
|
111
|
+
|
|
112
|
+
해소(doc): README 두 구절을 모두 delta 채널로 한정(`maxMatchBytes`까지 frame에 걸쳐 분할된 delta-text PII; non-delta leaf와 non-JSON frame은 within-frame 검사); `README.ko.md` 갱신.
|
|
113
|
+
|
|
114
|
+
### CR2-009: maxMessageBytes 검사 이후의 keyMaterial — FALSE POSITIVE
|
|
115
|
+
|
|
116
|
+
심각도: —(보고된 P2; 취약점이 아님으로 검증)
|
|
117
|
+
상태: Won't fix
|
|
118
|
+
영향 코드: `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
119
|
+
검증: 구조적 관찰(`keyMaterial` append 이후 결합 메시지가 재검사되지 않음)은 정확하지만, 공격자가 익스플로잇할 수 없다. `keyMaterial`은 운영자 통제이고(host가 운영자 선언 HTTPS URL에서 fetch, TTL 캐시, 공격자 영향 credential과 독립) guarded fetcher의 `maxBytes`(기본 1 MiB)로 hard-bound된다; credential은 base 검사로 경계가 유지된다. 결합 wire는 두 운영자 설정 상수로 경계화되며 공격자 증폭이 없다; "`maxBytes`를 임의로 크게"는 운영자 자체 오설정이다. 선택적 cosmetic defense-in-depth만 가능(결합 size re-assert); 보안 수정 불필요.
|
|
120
|
+
|
|
121
|
+
### CR2-010: Non-JSON Cross-Frame Split — 수용 잔여 리스크(문서화됨)
|
|
122
|
+
|
|
123
|
+
심각도: —(보고된 P2; 이미 문서화된 잔여 리스크)
|
|
124
|
+
상태: Accepted
|
|
125
|
+
영향 코드: `packages/core/index.mjs` / `packages/stream-filter/index.mjs`
|
|
126
|
+
검증: 1.3.1에서 실재한다(non-JSON CONTENT frame은 cross-frame buffer 없이 per-frame `protectText`를 받음). 하지만 round-1 `P1-CR-005` resolution, `threat-model.md` exclusions, in-code comment에 범위 외로 명시 문서화되어 있다. JSON delta 채널은 `maxMatchBytes`까지 cross-frame buffering을 한다. 코드 변경 불필요; 기껏해야 문서 다듬기 차원의 sibling exclusion 항목(CR2-008의 README scoping에 흡수).
|
|
127
|
+
|
|
128
|
+
## 보완 순서
|
|
129
|
+
|
|
130
|
+
1. `CR2-001`을 최우선으로 — 전제 조건 없이 인증되지 않은 클라이언트가 도달 가능한 유일한 항목(availability).
|
|
131
|
+
2. `CR2-002`와 `CR2-003`을 병렬로 — 파일이 disjoint하다(token-vault+audit vs plugin sandbox).
|
|
132
|
+
3. `CR2-004` + `CR2-005`를 함께(둘 다 `proxy/index.mjs`; CR2-001 이후 / 그 위에 rebase해 착륙).
|
|
133
|
+
4. `CR2-006` + `CR2-007` + `CR2-008` — 문서/help-text 묶음, 아무 때나.
|
|
134
|
+
5. `CR2-009` / `CR2-010`은 코드 변경 불필요(audit trail용 기록).
|
|
135
|
+
|
|
136
|
+
## 종료 규칙
|
|
137
|
+
|
|
138
|
+
항목은 코드/문서 보완이 merge되고, 집중 회귀 테스트 또는 명시적 non-test rationale이 기록되며, 릴리스 게이트 등록부(`G10`)가 증거를 링크할 때만 `Resolved`로 옮긴다. 1.3.2 컷이 resolved 항목과 `G10`을 함께 뒤집는다.
|
|
139
|
+
|
|
140
|
+
## 추적 링크
|
|
141
|
+
|
|
142
|
+
`docs/current/risk-register-release-gate.md`(§5.8 + `G10`)와 `docs/current/risk-register-release-gate.ko.md`에서 참조한다.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 2026-06-16 Code Review Risk Register — Round 2 (CR2)
|
|
2
|
+
|
|
3
|
+
Status: remediation complete and shipped in `haechi@1.3.2` (no P0/P1; CR2-001..008 Resolved; CR2-009 won't-fix, CR2-010 accepted; G10 Pass, 2026-06-16)
|
|
4
|
+
Scope: `main` at `36af9fd1eef2b1e19b19b2e0344faab0a7a3e83d` (post-1.3.1)
|
|
5
|
+
Review date: 2026-06-16
|
|
6
|
+
Source: a second deep review after the 1.3.1 remediation cut, with per-finding adversarial verification against the current code
|
|
7
|
+
|
|
8
|
+
This is a **second round** kept separate from `code-review-risk-register-2026-06-16.md` (round 1, all Resolved + shipped in 1.3.1), which is a frozen resolution record cited by the threat model and code. Round 2 captures findings raised after 1.3.1 and re-verified against the current tree; a few of them extend or qualify round-1 claims, so they are cross-referenced rather than written back into the frozen record.
|
|
9
|
+
|
|
10
|
+
## Release Decision
|
|
11
|
+
|
|
12
|
+
The round-1 P0/P1 are all Resolved and shipped in `haechi@1.3.1`. Round 2 found **no P0 and no P1**: the two findings raised as P1 in the external review both verified down to **P2** (neither is a stored-plaintext leak or an auth/SSRF bypass). The confirmed P2s are an availability/resource leak on the proxy data path, an audit-hygiene gap that reflects caller-supplied input, and an unbounded plugin IPC reply. The three P2s and the P3 cluster (`CR2-001..008`) are now **Resolved and shipped in `haechi@1.3.2`** (2026-06-16, attested OIDC publish); `CR2-009` stays won't-fix (false positive) and `CR2-010` stays accepted (documented residual). **G10 is Pass.** Operators should upgrade from `1.3.1` to `1.3.2` to pick up the CR2 fixes.
|
|
13
|
+
|
|
14
|
+
## Severity Policy
|
|
15
|
+
|
|
16
|
+
- `P0`: direct credential/data leak across a trust boundary, or a bypass that defeats the core security promise.
|
|
17
|
+
- `P1`: SSRF, protection bypass, denial-of-service, or protocol behavior that can break protected deployments.
|
|
18
|
+
- `P2`: operational, correctness, availability, or hygiene gaps that should be resolved before broad adoption.
|
|
19
|
+
- `P3`: low-impact hardening, finite-bound robustness, or documentation accuracy.
|
|
20
|
+
|
|
21
|
+
## Verification note
|
|
22
|
+
|
|
23
|
+
Every finding below was traced against the current code by an independent reviewer (not taken on the reporter's word). Two reported P1s were downgraded to P2 after verification (no inflation); one reported finding was a **false positive** and one is an **already-documented accepted residual** — both are recorded here for the audit trail and require no code change.
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
|
|
27
|
+
| ID | Severity | Area | Risk | Status |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
| CR2-001 | P2 | Proxy availability | Pass-through streaming never cancels the upstream reader on downstream client disconnect — `await once(response,"drain")` parks forever, leaking the upstream connection/task; an unauthenticated client can disconnect repeatedly to accumulate dangling upstream connections. | Resolved |
|
|
30
|
+
| CR2-002 | P2 | Audit hygiene | Token-vault reveal/purge failures write the raw caller-supplied `token` and `error.message` (which interpolates the token) into the audit event; `FORBIDDEN_KEYS` strips by key name only, so a secret passed where a `tok_` id is expected lands raw in the hash-chained log. Reflects caller input, not stored vault plaintext. | Resolved |
|
|
31
|
+
| CR2-003 | P2 | Plugin sandbox DoS | `maxMessageBytes` bounds only the host→plugin credential message; the plugin→host reply is received and `JSON.parse`d unbounded. The process-isolated child has no heap cap, so a hostile/buggy signed plugin can return an oversized reply → synchronous host parse stalls the event loop + memory spike. | Resolved |
|
|
32
|
+
| CR2-004 | P3 | Proxy headers | `sanitizeResponseHeaders` keeps body-coupled validators (`etag`/`content-md5`/`digest`/`last-modified`) when the body is transformed, so they become stale; no `cache-control: no-store` on a mutated response. | Resolved |
|
|
33
|
+
| CR2-005 | P3 | Proxy robustness | On a body over `maxBytes`, `readBody` rejects but does not stop reading/teardown the socket, so an upload is read-and-discarded until the (finite) Node `requestTimeout`. | Resolved |
|
|
34
|
+
| CR2-006 | P3 | MCP wrap | `mcp-wrap --stderr filter` protects per complete line, so a secret an adversarial child deliberately splits across a newline evades the anchored regex. Inherent to line-oriented filtering of a trusted local child's diagnostic output; a single-line secret IS caught and `--stderr drop` exists. Doc-only. | Resolved |
|
|
35
|
+
| CR2-007 | P3 | Docs | README says MCP wrap "stderr and exit codes pass through", but the default is now `--stderr filter` (round-1 P2-CR-006). | Resolved |
|
|
36
|
+
| CR2-008 | P3 | Docs | README's streaming "split match" claim is unscoped; cross-frame buffering applies to the JSON delta channel only, not arbitrary non-JSON frames. | Resolved |
|
|
37
|
+
| CR2-009 | — | Plugin sandbox | (Reported P2) `keyMaterial` is appended after the `maxMessageBytes` check on the base credential message. **FALSE POSITIVE:** `keyMaterial` is operator-controlled and hard-bounded by the fetcher's `maxBytes`; no attacker amplification. No fix required (optional cosmetic re-assert only). | Won't fix |
|
|
38
|
+
| CR2-010 | — | Streaming | (Reported P2) A secret split across two NON-JSON SSE/NDJSON frames is not caught (per-frame inspection). **ACCEPTED RESIDUAL — already documented** in round-1 P1-CR-005 resolution, `threat-model.md` exclusions, and an in-code comment. No change. | Accepted |
|
|
39
|
+
|
|
40
|
+
## Detailed Findings
|
|
41
|
+
|
|
42
|
+
### CR2-001: Upstream Reader Not Cancelled On Downstream Disconnect
|
|
43
|
+
|
|
44
|
+
Severity: P2 (the most urgent CR2 item)
|
|
45
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
46
|
+
Affected code: `packages/proxy/index.mjs` `pipeUpstreamBodyBounded` / `forward`
|
|
47
|
+
Verified: in the pass-through streaming path there is no `close`/`aborted` listener on the client connection; `await once(response, "drain")` parks indefinitely after the client socket dies (neither `drain` nor `error` fires), so the async task and the upstream connection leak. Reachable by an **unauthenticated** client with no preconditions; repeated mid-stream disconnects accumulate dangling upstream connections against the proxy and its upstream LLM endpoint.
|
|
48
|
+
|
|
49
|
+
Resolution: pass a per-request `AbortController` into `forward()` (aborting the upstream fetch) and register a one-shot client `close`/`aborted` listener that cancels the upstream reader; race the `drain` wait against `close` so backpressure waits unpark on disconnect; cover the no-backpressure `reader.read()` parked case too. Regression test: disconnect mid-stream and assert prompt reader cancellation / upstream abort.
|
|
50
|
+
|
|
51
|
+
### CR2-002: Token-Vault Reveal/Purge Writes Raw Token + Error Text To Audit
|
|
52
|
+
|
|
53
|
+
Severity: P2 (downgraded from a reported P1 — it reflects caller-supplied input, not stored vault plaintext)
|
|
54
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #97)
|
|
55
|
+
Affected code: `packages/token-vault/index.mjs` (reveal/purge throw + record), `packages/audit/index.mjs` (`FORBIDDEN_KEYS` / `sanitizeAudit`)
|
|
56
|
+
Verified: reveal throws `Unknown token: ${token}` / `Token expired: ${token}` and the catch records `reason: error.message`; the raw `token` argument is also written verbatim on `reveal_failed`/`reveal_denied`/`purge`. `sanitizeAudit` filters by key name and `FORBIDDEN_KEYS` contains neither `reason` nor `token`, so both survive into the written, hash-chained record. In legitimate flows the `token` argument is a non-sensitive `tok_<type>_<hash>` id, so the leak only fires when a caller/operator passes a raw secret where a token id is expected — but it still contradicts the "no plaintext / keyed-HMAC only" wording in round-1 `P1-SEC-017` and `threat-model.md`.
|
|
57
|
+
|
|
58
|
+
Resolution: (1) generic error messages (no raw token interpolation); (2) stop writing the raw `token` verbatim — keyed-HMAC it (as `subjectHash`/`issuerHash` are) or validate the argument against the `tok_<type>_<hash>` shape and redact otherwise, before reveal/purge records; (3) replace free-text `reason: error.message` with an enum `reasonCode`; (4) regression test that a `reveal_failed`/`purge` event never contains a raw caller-supplied token in `reason`/`token`; reconcile the invariant wording in the docs.
|
|
59
|
+
|
|
60
|
+
### CR2-003: Plugin IPC Reply Not Size-Bounded; Process Child Has No Heap Cap
|
|
61
|
+
|
|
62
|
+
Severity: P2
|
|
63
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #98)
|
|
64
|
+
Affected code: `packages/plugin/sandbox.mjs`, `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
65
|
+
Verified: `maxMessageBytes` is enforced only on the outbound host→plugin credential message; the inbound reply is received and `JSON.parse`d with no size check in both sandboxes. The worker has an implicit bound (a required `resourceLimits` heap cap OOMs a runaway worker first), but the process child sets no `--max-old-space-size`, so a hostile/buggy signed plugin can build a reply up to the child's default V8 heap and `process.send` it; the host's synchronous `JSON.parse` stalls the event loop (the per-call timeout cannot fire mid-parse). Requires a signed/semi-trusted-but-hostile plugin.
|
|
66
|
+
|
|
67
|
+
Resolution: bound the reply BEFORE parsing in both sandboxes (check byte length against `maxMessageBytes` or a dedicated `maxReplyBytes` in the worker/child `message` handler, drop oversized as a deny before `JSON.parse`); give the process child a heap cap via `--max-old-space-size` derived from a new `resourceLimits`/`processMaxOldGenerationSizeMb` knob. Regression test: a fixture plugin returning an oversized claims object → deny without unbounded host work. Update the 1.0/1.1 scope docs to state the bound applies in BOTH directions.
|
|
68
|
+
|
|
69
|
+
### CR2-004: Stale Body-Coupled Validator Headers On Transformed Responses
|
|
70
|
+
|
|
71
|
+
Severity: P3
|
|
72
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
73
|
+
Affected code: `packages/proxy/index.mjs` `sanitizeResponseHeaders` / `transformedJsonHeaders`
|
|
74
|
+
Verified: only hop-by-hop headers are stripped; when `protectJson` mutates and re-serializes the body, upstream `etag`/`content-md5`/`digest`/`last-modified` survive unchanged and no `cache-control: no-store` is set. Real-world impact is small for the documented inference-upstream target set (POST responses, no strong validators, not cacheable by default per RFC 9111; `content-length` IS recomputed), but it is not recorded as an accepted residual.
|
|
75
|
+
|
|
76
|
+
Resolution: add `etag`/`content-md5`/`digest`/`last-modified` to the dropped set on every body-mutating path; set `cache-control: no-store` on a transformed response. Test: a mutated response no longer carries the upstream `ETag`.
|
|
77
|
+
|
|
78
|
+
### CR2-005: Over-Limit Request Body Not Drained/Torn Down
|
|
79
|
+
|
|
80
|
+
Severity: P3
|
|
81
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
82
|
+
Affected code: `packages/proxy/index.mjs` `readBody`
|
|
83
|
+
Verified: on exceeding `maxBytes`, `readBody` sets a flag and rejects but does not `pause()`/`destroy()` the request, and the 413 response sends no `Connection: close`, so Node reads-and-discards the rest of the upload until the built-in `requestTimeout` (Node ≥22 default 300000 ms). The hold is finite; `maxInFlight: 0` (default) does not bound simultaneous held connections.
|
|
84
|
+
|
|
85
|
+
Resolution: on 413, `request.pause()`/`request.destroy()` (or `Connection: close` before the response) so the socket releases promptly. Lower priority: ship non-null default `requestTimeoutMs`/`headersTimeoutMs` and document that `maxInFlight: 0` leaves concurrency unbounded.
|
|
86
|
+
|
|
87
|
+
### CR2-006: mcp-wrap `--stderr filter` Cannot Catch A Newline-Split Secret
|
|
88
|
+
|
|
89
|
+
Severity: P3 (doc)
|
|
90
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
91
|
+
Affected code: `packages/cli/bin/haechi.mjs` `pipeFilteredStderr` / `protectStderrLine`
|
|
92
|
+
Verified: `filter` splits child stderr on `\n` and protects each complete line with a fresh single-shot protector, so a secret an adversarial child deliberately emits split across a newline evades the anchored full-secret regex. Narrow: the child is the operator's trusted local MCP server, a single-line secret IS caught, and `--stderr drop` exists. This is an inherent property of line-oriented text filtering, not an exploitable bypass of the request/response protection path.
|
|
93
|
+
|
|
94
|
+
Resolution (doc): one sentence in `COMMAND_HELP` and this register noting `filter` protects per complete line and cannot catch a secret split across a newline; recommend `--stderr drop` for high-sensitivity tools. Optional later code hardening: route stderr through the push/flush sliding-buffer channel (`maxMatchBytes`) instead of per-line `protectText`.
|
|
95
|
+
|
|
96
|
+
### CR2-007: README mcp-wrap stderr Passthrough Is Stale
|
|
97
|
+
|
|
98
|
+
Severity: P3 (doc)
|
|
99
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
100
|
+
Affected code: `README.md`
|
|
101
|
+
Verified: README says "stderr and exit codes pass through", but the default is now `--stderr filter` (round-1 P2-CR-006); raw passthrough is only the opt-in `inherit` mode. Exit codes do pass through, so only the stderr clause is stale; `COMMAND_HELP` is already accurate.
|
|
102
|
+
|
|
103
|
+
Resolution (doc): correct the README line to reflect the `filter` default (`inherit` for raw, `drop` to discard; `filter` transforms only under `policy.mode: enforce`); update the `README.ko.md` sibling.
|
|
104
|
+
|
|
105
|
+
### CR2-008: README Streaming Split-Match Claim Is Unscoped
|
|
106
|
+
|
|
107
|
+
Severity: P3 (doc)
|
|
108
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
109
|
+
Affected code: `README.md`
|
|
110
|
+
Verified: the README claims PII split across frames is caught without scoping it to the JSON delta channel; non-JSON CONTENT frames get single-shot per-frame `protectText` (no cross-frame buffer). The claim overstates the guarantee relative to `threat-model.md` and the scope docs.
|
|
111
|
+
|
|
112
|
+
Resolution (doc): scope both README passages to the delta channel (delta-text PII split across frames up to `maxMatchBytes`; non-delta leaves and non-JSON frames inspected within-frame); update `README.ko.md`.
|
|
113
|
+
|
|
114
|
+
### CR2-009: keyMaterial After the maxMessageBytes Check — FALSE POSITIVE
|
|
115
|
+
|
|
116
|
+
Severity: — (reported P2; verified not a vulnerability)
|
|
117
|
+
Status: Won't fix
|
|
118
|
+
Affected code: `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
119
|
+
Verified: the structural observation (the combined message is not re-checked after `keyMaterial` is appended) is accurate, but it is not attacker-exploitable. `keyMaterial` is operator-controlled (fetched by the host from an operator-declared HTTPS URL, TTL-cached, independent of the attacker-influenced credential) and hard-bounded by the guarded fetcher's `maxBytes` (default 1 MiB); the credential stays bounded by the base check. The combined wire is bounded by two operator-set constants with no attacker amplification; "`maxBytes` arbitrarily large" is operator self-misconfiguration. Optional cosmetic defense-in-depth only (re-assert the combined size); no security fix required.
|
|
120
|
+
|
|
121
|
+
### CR2-010: Non-JSON Cross-Frame Split — ACCEPTED RESIDUAL (documented)
|
|
122
|
+
|
|
123
|
+
Severity: — (reported P2; an already-documented residual)
|
|
124
|
+
Status: Accepted
|
|
125
|
+
Affected code: `packages/core/index.mjs` / `packages/stream-filter/index.mjs`
|
|
126
|
+
Verified: real in 1.3.1 (non-JSON CONTENT frames get per-frame `protectText` with no cross-frame buffer), but it is explicitly documented as out-of-scope in round-1 `P1-CR-005` resolution, the `threat-model.md` exclusions, and an in-code comment. The JSON delta channel DOES cross-frame buffer up to `maxMatchBytes`. No code change required; at most a documentation-polish sibling exclusion bullet (folded into CR2-008's README scoping).
|
|
127
|
+
|
|
128
|
+
## Remediation Order
|
|
129
|
+
|
|
130
|
+
1. `CR2-001` first — the only finding reachable by an unauthenticated client with no preconditions (availability).
|
|
131
|
+
2. `CR2-002` and `CR2-003` in parallel — file-disjoint (token-vault+audit vs plugin sandbox).
|
|
132
|
+
3. `CR2-004` + `CR2-005` together (both `proxy/index.mjs`; land after / rebased on CR2-001).
|
|
133
|
+
4. `CR2-006` + `CR2-007` + `CR2-008` — documentation/help-text cluster, anytime.
|
|
134
|
+
5. `CR2-009` / `CR2-010` need no code change (recorded for the audit trail).
|
|
135
|
+
|
|
136
|
+
## Closure Rules
|
|
137
|
+
|
|
138
|
+
An item moves to `Resolved` only when the code/doc remediation is merged, a focused regression test or explicit non-test rationale is recorded, and the release-gate register (`G10`) links the evidence. The 1.3.2 cut flips the resolved items and `G10` together.
|
|
139
|
+
|
|
140
|
+
## Traceability
|
|
141
|
+
|
|
142
|
+
Linked from `docs/current/risk-register-release-gate.md` (§5.8 + `G10`) and `docs/current/risk-register-release-gate.ko.md`.
|
|
@@ -140,7 +140,27 @@ HAECHI_BENCH_REQUESTS=5000 HAECHI_BENCH_CONCURRENCY=64 npm run bench:throughput
|
|
|
140
140
|
> 네트워크/하드웨어 처리량 벤치마크가 **아니며** 보장 수치로 인용해서는 **안
|
|
141
141
|
> 됩니다**. 이 벤치는 `release:preflight`에서 실행되지 않습니다.
|
|
142
142
|
|
|
143
|
-
## 8.
|
|
143
|
+
## 8. 실 업스트림 검증 (real vLLM / Ollama)
|
|
144
|
+
|
|
145
|
+
`local-inference` 통합 스위트는 요청을 **실제** OpenAI 호환(vLLM) 및/또는
|
|
146
|
+
Ollama 업스트림으로 프록시하여, 프록시가 올바르게 왕복하는지 검증합니다(실제
|
|
147
|
+
소켓 위에서의 adapter 라우팅 + 요청/응답 보호). 이 스위트는 env-gated되어, 백엔드를
|
|
148
|
+
가리키지 않으면 **스킵**합니다 — CI는 프로토콜 스텁을 상대로 실행합니다(실제 vLLM은
|
|
149
|
+
GPU가 필요하고 GitHub 호스팅 러너에서 도달할 수 없습니다). 도달 가능한 호스트에서
|
|
150
|
+
본인의 백엔드를 상대로 검증하려면:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
|
|
154
|
+
HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
|
|
155
|
+
npm run test:inference:live
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
보유한 백엔드만 설정하십시오 — 각 테스트는 해당 URL이 설정되지 않으면 스킵합니다.
|
|
159
|
+
본인의 호스트/IP를 사용하십시오(커밋하지 마십시오). 지속적으로 구동되는 실제 백엔드
|
|
160
|
+
게이트가 필요하면, 해당 네트워크에 self-hosted 러너를 등록하고 그곳에서 스위트를
|
|
161
|
+
트리거하십시오. GitHub 호스팅 러너는 사설 LAN에 도달할 수 없습니다.
|
|
162
|
+
|
|
163
|
+
## 9. 빠른 참조
|
|
144
164
|
|
|
145
165
|
| 작업 | 커맨드 |
|
|
146
166
|
|---|---|
|
|
@@ -225,7 +225,28 @@ default 2000), `HAECHI_BENCH_CONCURRENCY` (default 32), `HAECHI_BENCH_WARMUP`
|
|
|
225
225
|
> and must **not** be quoted as guarantees. The bench is not run by
|
|
226
226
|
> `release:preflight`.
|
|
227
227
|
|
|
228
|
-
## 8.
|
|
228
|
+
## 8. Live upstream validation (real vLLM / Ollama)
|
|
229
|
+
|
|
230
|
+
The `local-inference` integration suite proxies a request through to a **real**
|
|
231
|
+
OpenAI-compatible (vLLM) and/or Ollama upstream and asserts the proxy round-trips
|
|
232
|
+
correctly (adapter routing + request/response protection over a real socket). It
|
|
233
|
+
is env-gated, so it **skips** unless you point it at a backend — CI runs it
|
|
234
|
+
against a protocol stub (a real vLLM needs a GPU and is not reachable from a
|
|
235
|
+
GitHub-hosted runner). To validate against your own backend from a host that can
|
|
236
|
+
reach it:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
|
|
240
|
+
HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
|
|
241
|
+
npm run test:inference:live
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Set only the backend(s) you have — each test skips when its URL is unset. Use
|
|
245
|
+
your own host/IP (do not commit it). For a continuously-exercised real-backend
|
|
246
|
+
gate, register a self-hosted runner on that network and trigger the suite there;
|
|
247
|
+
GitHub-hosted runners cannot reach a private LAN.
|
|
248
|
+
|
|
249
|
+
## 9. Quick reference
|
|
229
250
|
|
|
230
251
|
| Task | Command |
|
|
231
252
|
|---|---|
|
|
@@ -27,7 +27,9 @@ npm run release:preflight:npm
|
|
|
27
27
|
|
|
28
28
|
1. ✅ npmjs.com에서: package settings → Trusted Publisher → `raeseoklee/haechi` 저장소와 `npm-publish.yml` workflow 연결 (2026-06-10).
|
|
29
29
|
2. ✅ `.github/workflows/npm-publish.yml` OIDC 인증 전환 (2026-06-10): `NODE_AUTH_TOKEN`과 `registry-url` 제거, runner의 npm CLI를 `>= 11.5.1`로 업그레이드.
|
|
30
|
-
3. ✅ `haechi@0.4.0`으로 검증 완료 (2026-06-10): `npm view haechi --json`에서 SLSA provenance v1 predicate를 가진 `dist.attestations` 확인.
|
|
30
|
+
3. ✅ `haechi@0.4.0`으로 검증 완료 (2026-06-10): `npm view haechi --json`에서 SLSA provenance v1 predicate를 가진 `dist.attestations` 확인.
|
|
31
|
+
|
|
32
|
+
**비증명 버전(로컬 패스키 첫 발행):** `haechi@0.3.2`와 `haechi-ratelimit-redis@0.1.0`(2026-06-16)은 각각 로컬 머신에서 `--provenance=false`로 배포되어 두 버전의 provenance 증명이 존재하지 않습니다 — 둘 다 아직 존재하지 않던 패키지의 **이름을 확보하는 첫 발행**이었기 때문입니다(Trusted Publisher가 완전히 새로운 이름을 부트스트랩할 수 없는 이유는 §5 참조). 각 패키지의 이후 모든 버전은 OIDC workflow로 증명됩니다.
|
|
31
33
|
|
|
32
34
|
provenance 없이 수행한 publish는 release note에 갭을 명시적으로 기록해야 합니다(`CONTRIBUTING.md` 참조).
|
|
33
35
|
|
|
@@ -80,10 +82,16 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
|
|
|
80
82
|
|
|
81
83
|
**satellite별 부트스트랩 순서(첫 발행, org 불필요):**
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
2. 접두사 태그를 push하고 GitHub Release를 발행하면(예: `crypto-kms-v0.1.0`) 워크플로의 OIDC publish가 provenance와 함께 `0.1.0`을 생성하고 첫 발행 시 이름을 확보합니다.
|
|
85
|
+
아직 존재하지 않는 이름에는 Trusted Publisher를 설정할 **수 없습니다** — npm은 **이미 존재하는** 패키지의 설정 페이지에서만 Trusted Publisher 설정을 노출합니다. 따라서 완전히 새로운 unscoped 이름은 두 단계 부트스트랩을 거칩니다: 먼저 수동 첫 발행으로 이름을 *생성하고 확보*한 뒤, Trusted Publisher를 설정하여 이후 모든 버전이 OIDC로 증명되게 합니다.
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
1. **수동 첫 발행(이름 확보; 로컬, provenance 없음).** satellite 디렉터리에서, 패스키/WebAuthn 계정이 터미널 OTP 없이 인증되도록 브라우저로 인증한 뒤 provenance를 끄고 발행합니다(로컬 머신에는 OIDC id-token이 없어 증명할 수 없습니다).
|
|
88
|
+
```bash
|
|
89
|
+
npm login --auth-type=web
|
|
90
|
+
cd satellites/<name> && npm publish --auth-type=web --provenance=false
|
|
91
|
+
```
|
|
92
|
+
각 satellite `package.json`의 `publishConfig.access: "public"`이 unscoped 패키지를 public으로 만듭니다. 이 첫 버전은 **비증명**입니다 — §2 / `CONTRIBUTING.md`에 따라 갭을 기록하세요.
|
|
93
|
+
2. **이제 패키지가 존재하므로 → Trusted Publisher 설정**: npmjs.com에서 package settings → Trusted Publisher → `raeseoklee/haechi` 저장소와 satellite의 **정확한 워크플로 파일명**(예: `crypto-kms-publish.yml`)을 연결합니다.
|
|
94
|
+
3. **이후 모든 버전은 OIDC로 증명됩니다.** satellite `package.json`을 bump하고, 접두사 태그를 push한 뒤, GitHub Release를 발행하면(예: `crypto-kms-v0.1.1`) 워크플로의 OIDC publish가 provenance와 함께 해당 버전을 발행합니다. 이 시점부터는 노트북도 OTP도 필요 없습니다. 이름이 unscoped이고 비어있으므로 org-membership 선행 요건이 없습니다.
|
|
87
95
|
|
|
88
96
|
**태그 → 워크플로 → 패키지 매핑:**
|
|
89
97
|
|
|
@@ -104,9 +112,9 @@ npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "p
|
|
|
104
112
|
|
|
105
113
|
**의존성 노트:** `haechi-crypto-kms`는 core를 zero-dependency로 유지합니다 — `@aws-sdk/client-kms`는 **optional peer dependency**이며, 실제 AWS 클라이언트를 쓰고 주입하지 않을 때만 lazy import됩니다. in-memory 또는 주입형 클라이언트를 쓰는 소비자는 SDK를 설치하지 않습니다. 0.2.0의 `./gcp`(`@google-cloud/kms`)와 `./azure`(`@azure/keyvault-keys` + `@azure/identity`) 백엔드도 동일한 optional-peer/lazy-import 모델을 따르며, `./vault` 백엔드는 optional peer가 없습니다(`node:` `fetch` 전용).
|
|
106
114
|
|
|
107
|
-
**0.9 satellite(새 unscoped 이름
|
|
115
|
+
**0.9 satellite(새 unscoped 이름):** `haechi-dashboard`와 `haechi-auth-oidc`는 0.9에서 위의 두 단계 부트스트랩으로 첫 발행되었습니다 — 수동 첫 발행으로 각 이름을 확보한 뒤 Trusted Publisher를 설정했고, 그 이후 태그 릴리스(`dashboard-v<semver>`, `auth-oidc-v<semver>`)는 OIDC로 발행됩니다. 0.8 satellite 두 개는 이미 존재하므로 이미 부트스트랩된 태그/워크플로를 그대로 사용합니다: `haechi-auth-jwt`는 `auth-jwt-v<semver>`(`auth-jwt-publish.yml`), `haechi-crypto-kms`는 `crypto-kms-v<semver>`(`crypto-kms-publish.yml`) — 이 둘은 새 Trusted Publisher 설정이 필요 없습니다.
|
|
108
116
|
|
|
109
|
-
**`haechi-ratelimit-redis`(
|
|
117
|
+
**`haechi-ratelimit-redis`(부트스트랩 2026-06-16):** 공유 저장소 rate-limiter satellite는 위의 두 단계 부트스트랩을 따랐습니다. `0.1.0`은 이름을 확보한 **수동 첫 발행**(로컬 패스키 web 인증, `--provenance=false`)이므로 **비증명**입니다(§2에 기록). 이후 Trusted Publisher(`ratelimit-redis-publish.yml`)를 설정했고, `0.1.1`부터의 모든 버전은 `ratelimit-redis-v<semver>` 태그 → 워크플로로 provenance와 함께 발행됩니다. `redis` 클라이언트는 **optional peer dependency**이며 번들된 Redis 어댑터를 쓰는 소비자만 import합니다(store/client는 주입됩니다). 따라서 core는 zero-dependency로 유지됩니다.
|
|
110
118
|
|
|
111
119
|
## 6. 배포 차단 조건
|
|
112
120
|
|
|
@@ -27,7 +27,9 @@ The intended publish path is GitHub Actions trusted publishing: npm authenticate
|
|
|
27
27
|
|
|
28
28
|
1. ✅ On npmjs.com: package settings → Trusted Publisher → linked the `raeseoklee/haechi` repository and the `npm-publish.yml` workflow (2026-06-10).
|
|
29
29
|
2. ✅ `.github/workflows/npm-publish.yml` authenticates via OIDC (2026-06-10): `NODE_AUTH_TOKEN` and `registry-url` removed, npm CLI upgraded to `>= 11.5.1` in the runner.
|
|
30
|
-
3. ✅ Verified with `haechi@0.4.0` (2026-06-10): `npm view haechi --json` shows `dist.attestations` with a SLSA provenance v1 predicate.
|
|
30
|
+
3. ✅ Verified with `haechi@0.4.0` (2026-06-10): `npm view haechi --json` shows `dist.attestations` with a SLSA provenance v1 predicate.
|
|
31
|
+
|
|
32
|
+
**Unattested versions (local passkey first publishes):** `haechi@0.3.2` and `haechi-ratelimit-redis@0.1.0` (2026-06-16) were each published from a local machine with `--provenance=false`, so no provenance attestation exists for those two versions — both were the **name-claiming first publish** of a package that did not yet exist (see §5 on why a Trusted Publisher cannot bootstrap a brand-new name). Every later version of each package is attested via the OIDC workflow.
|
|
31
33
|
|
|
32
34
|
Any publish performed without provenance must record the gap explicitly in the release notes (see `CONTRIBUTING.md`).
|
|
33
35
|
|
|
@@ -80,10 +82,16 @@ Satellites live under `satellites/*` in the npm workspaces monorepo and publish
|
|
|
80
82
|
|
|
81
83
|
**Per-satellite bootstrap order (first publish, no org needed):**
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
2. Push the prefixed tag and publish a GitHub Release (e.g. `crypto-kms-v0.1.0`) → the workflow's OIDC publish creates `0.1.0` with provenance and claims the name on first publish.
|
|
85
|
+
A Trusted Publisher **cannot** be configured for a name that does not exist yet — npm only exposes the Trusted Publisher setting on an **existing** package's settings page. So a brand-new unscoped name has a two-phase bootstrap: a manual first publish to *create and claim* the name, then Trusted-Publisher configuration so every later version is OIDC-attested.
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
1. **Manual first publish (claims the name; local, no provenance).** From the satellite directory, authenticate via the browser so a passkey/WebAuthn account needs no terminal OTP, then publish with provenance off (a local machine has no OIDC id-token, so it cannot attest):
|
|
88
|
+
```bash
|
|
89
|
+
npm login --auth-type=web
|
|
90
|
+
cd satellites/<name> && npm publish --auth-type=web --provenance=false
|
|
91
|
+
```
|
|
92
|
+
`publishConfig.access: "public"` in each satellite's `package.json` makes the unscoped package public. This first version is **unattested** — record the gap per §2 / `CONTRIBUTING.md`.
|
|
93
|
+
2. **Now the package exists → configure a Trusted Publisher** on npmjs.com: package settings → Trusted Publisher → link the `raeseoklee/haechi` repository and the satellite's **exact workflow filename** (e.g. `crypto-kms-publish.yml`).
|
|
94
|
+
3. **Every subsequent version is OIDC-attested.** Bump the satellite `package.json`, push the prefixed tag, and publish a GitHub Release (e.g. `crypto-kms-v0.1.1`) → the workflow's OIDC publish ships that version with provenance. No laptop and no OTP from here on. Because the names are unscoped and free, there is no org-membership prerequisite.
|
|
87
95
|
|
|
88
96
|
**Tag → workflow → package mapping:**
|
|
89
97
|
|
|
@@ -104,9 +112,9 @@ npm view haechi-crypto-kms --json # dist.attestations present; access "public"
|
|
|
104
112
|
|
|
105
113
|
**Dependency note:** `haechi-crypto-kms` keeps core zero-dependency — `@aws-sdk/client-kms` is an **optional peer dependency**, imported lazily only when a real AWS client is used and not injected. Consumers who use the in-memory or an injected client never install the SDK. The 0.2.0 `./gcp` (`@google-cloud/kms`) and `./azure` (`@azure/keyvault-keys` + `@azure/identity`) backends follow the same optional-peer/lazy-import model; the `./vault` backend has zero optional peer (`node:` `fetch` only).
|
|
106
114
|
|
|
107
|
-
**0.9 satellites (new unscoped names
|
|
115
|
+
**0.9 satellites (new unscoped names):** `haechi-dashboard` and `haechi-auth-oidc` were first-published in 0.9 via the two-phase bootstrap above — a manual first publish to claim each name, then the Trusted Publisher, after which their tagged releases (`dashboard-v<semver>`, `auth-oidc-v<semver>`) publish via OIDC. The two 0.8 satellites already exist and ride their already-bootstrapped tags/workflows: `haechi-auth-jwt` on `auth-jwt-v<semver>` (`auth-jwt-publish.yml`) and `haechi-crypto-kms` on `crypto-kms-v<semver>` (`crypto-kms-publish.yml`) — no new Trusted Publisher configuration is required for those two.
|
|
108
116
|
|
|
109
|
-
**`haechi-ratelimit-redis` (
|
|
117
|
+
**`haechi-ratelimit-redis` (bootstrapped 2026-06-16):** the shared-store rate-limiter satellite followed the two-phase bootstrap above. `0.1.0` was the **manual first publish** (local passkey web auth, `--provenance=false`) that claimed the name — so it is **unattested** (recorded in §2). The Trusted Publisher (`ratelimit-redis-publish.yml`) was then configured, and every version from `0.1.1` on is published via the `ratelimit-redis-v<semver>` tag → workflow with provenance. The `redis` client is an **optional peer dependency**, imported only by consumers using the bundled Redis adapter (the store/client is injected), so core stays zero-dependency.
|
|
110
118
|
|
|
111
119
|
## 6. Deployment block conditions
|
|
112
120
|
|
|
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
14
14
|
| 구분 | 판단 | 이유 |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
|
|
17
|
-
| GitHub release/tag | 허용 (`v1.3.
|
|
18
|
-
| npm stable | `haechi@1.3.
|
|
19
|
-
| production use | 운영자 게이트; `1.3.
|
|
17
|
+
| GitHub release/tag | 허용 (`v1.3.2` 릴리스됨) | `v1.3.2` CR2 보완 컷이 태깅·릴리스됨; §5.7 및 §5.8(`CR2-001..008`) 항목이 모두 Resolved이고 G9/G10은 Pass |
|
|
18
|
+
| npm stable | `haechi@1.3.2` publish됨 | CR2 보완이 `haechi@1.3.2` attested OIDC publish(2026-06-16)로 발행됨; 이전 `1.3.1`은 CR2 수정 이전 동작을 담고 있음 |
|
|
19
|
+
| production use | 운영자 게이트; `1.3.2`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; `haechi@1.3.1` 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 CR2 수정(특히 `CR2-001` 프록시 upstream-cancel과 `CR2-002` token-vault audit hygiene)을 반영하도록 `1.3.2`로 업그레이드해야 함 |
|
|
20
20
|
|
|
21
21
|
## 2. 릴리스 게이트
|
|
22
22
|
|
|
@@ -32,6 +32,7 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
32
32
|
| G7 | 1.2.0 신뢰성 강화 트랙 (WS1–WS6) | 탐지 품질 측정+강화(WS2: 라벨 코퍼스 precision/recall `bench:detection` 게이트, 자격증명+국제 PII 커버리지, 하드블록 타입 불변식이 적용된 `filters.minConfidence` / `filters.allowlist`, offset 무결성을 갖춘 NFKC 유니코드 회피 폴딩); WS3 주입 가능한 `rateLimiter` 시임 + bounded fixed-window map; WS4 운영성(`/__haechi/live`+`/ready` 분리, 주입 가능한 `/metrics`, 구조적 로그 + 요청별 `correlationId`, graceful drain, max-in-flight backpressure, env overlay, 하드닝 Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind 하드닝(`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST 컨트롤 매핑 백서 + RFC 9116 `security.txt` + 취약점 공개 경로. 모든 변경은 1.1 동작을 보존하는 기본값 뒤의 additive(`tests/api-contract.test.mjs` 통과); no-plaintext-in-audit 불변식이 텔레메트리까지 확장; core는 zero runtime dependency 유지; core 1.2.0 bump(additive 마이너) | Pass |
|
|
33
33
|
| G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
|
|
34
34
|
| G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
|
+
| G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
35
36
|
|
|
36
37
|
## 3. P0 배포 차단 리스크 상태
|
|
37
38
|
|
|
@@ -151,6 +152,23 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
151
152
|
| P2-CR-012 | KMS vault IPv6 loopback carve-out의 IPv6 테스트 부족 | Resolved | `satellites/crypto-kms/vault.test.mjs`에 전용 IPv6 loopback 정책 테스트("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012")를 추가해 bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, hex `::ffff:7f00:1`/`::ffff:7f00:0001`(및 bracketed 변형)을 검증하고, 공인 mapped 주소(`::ffff:8.8.8.8`/`::ffff:808:808`)가 과차단되지 않음을 단언; 확장된 range table과 `ssrf-parity.test.mjs`가 auth-jwt와의 dotted+hex 일치를 고정 |
|
|
152
153
|
| P2-CR-013 | SSE multi-line `data:` 필드를 newline separator 없이 합침 | Resolved | `parseFrame`이 여러 `data:` line을 `join("\n")`(스펙 separator)으로 합치고 line별 스펙 선행 공백 1개만 제거; multi-line JSON은 여전히 `JSON.parse`되고 multi-line plain text는 newline과 함께 재구성되어 검사되며 `serializeTextFrame`가 multi-line payload를 여러 `data:` line으로 재방출; `tests/stream-filter.test.mjs`가 multi-line JSON event와 PII 포함 multi-line plain-text event를 커버 |
|
|
153
154
|
|
|
155
|
+
## 5.8 2026-06-16 코드리뷰 Round 2 (CR2) 상태 — 게이트 G10
|
|
156
|
+
|
|
157
|
+
권위 있는 항목별 등록부는 `docs/current/code-review-risk-register-2026-06-16-round2.md`입니다; 이 절은 릴리스 게이트 요약입니다. 1.3.1 컷 이후 진행한 2차 심층 리뷰는 **P0도 P1도 발견하지 못했습니다**(외부에서 P1로 보고된 두 항목 모두 검증 결과 P2로 내려갔습니다 — 둘 다 stored-plaintext leak도, auth/SSRF 우회도 아닙니다). 세 개의 P2 + P3 묶음(`CR2-001..008`)은 **Resolved이며 `haechi@1.3.2`로 발행되었습니다**; 보고된 한 항목은 **false positive**(`CR2-009`, won't-fix)였고 한 항목은 **이미 문서화된 수용 잔여 리스크**(`CR2-010`, accepted)였습니다. **G10은 Pass입니다.**
|
|
158
|
+
|
|
159
|
+
| ID | 리스크 | 상태 | 종료에 필요한 증거 |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| CR2-001 | pass-through streaming이 downstream disconnect 시 upstream reader를 절대 취소하지 않음(`pipeUpstreamBodyBounded`가 `drain`에서 영원히 park) — 인증되지 않은 resource leak | Resolved | per-request `AbortController` + upstream reader를 취소하고 fetch를 abort하는 클라이언트 `close`/`aborted` listener; `drain` 대기를 `close`와 race; 스트림 도중 disconnect가 reader를 즉시 취소하는 회귀 테스트 |
|
|
162
|
+
| CR2-002 | token-vault reveal/purge가 호출자 제공 raw `token` + `error.message`(token interpolate됨)를 audit event에 기록; `FORBIDDEN_KEYS`는 key 이름으로만 제거 | Resolved | 일반화된 오류 메시지; 기록 이전에 `token`을 keyed-HMAC하거나 `tok_` 형태로 검증; `error.message` 대신 enum `reasonCode`; raw token이 `reason`/`token`에 도달하지 않는다는 회귀 테스트; 불변식 표현 정합화 |
|
|
163
|
+
| CR2-003 | plugin IPC reply가 `JSON.parse` 이전에 size-bound되지 않음; process child에 heap cap 없음 → 적대적 signed plugin으로 인한 event-loop 정지 + 메모리 급증 | Resolved | 두 sandbox 모두에서 parse 이전 reply byte-length 검사(oversized를 deny로 drop); 새 `resourceLimits` knob을 통한 process child의 `--max-old-space-size` heap cap; oversized-reply fixture 회귀 테스트 |
|
|
164
|
+
| CR2-004 | `sanitizeResponseHeaders`가 변환된 응답에 stale body-coupled validator(`etag`/`content-md5`/`digest`/`last-modified`)를 유지 | Resolved | 모든 body-mutating 경로에서 해당 헤더 drop + `cache-control: no-store`; 변경된 응답이 upstream `ETag`를 drop하는 테스트 |
|
|
165
|
+
| CR2-005 | `maxBytes` 초과 request body가 (유한한) Node `requestTimeout`까지 read-and-discard됨 — socket teardown 없음 | Resolved | 413 경로에서 `request.pause()`/`destroy()`(또는 `Connection: close`); 선택적으로 non-null 기본 timeout |
|
|
166
|
+
| CR2-006 | `mcp-wrap --stderr filter`가 라인 지향이라 newline-split secret이 회피함(본질적; single-line secret은 잡힘, `drop` 사용 가능) | Resolved | `COMMAND_HELP` + 등록부 노트; 고민감 도구에 `--stderr drop` 권장 |
|
|
167
|
+
| CR2-007 | README가 mcp-wrap "stderr ... pass through"라고 하지만 기본값은 이제 `--stderr filter` | Resolved | README + `README.ko.md` 수정 |
|
|
168
|
+
| CR2-008 | README streaming split-match 주장이 범위 한정 없음(cross-frame buffering은 delta 채널만) | Resolved | README 두 구절 + `README.ko.md`를 delta 채널로 한정 |
|
|
169
|
+
| CR2-009 | (보고된 P2) credential `maxMessageBytes` 검사 이후 append된 `keyMaterial` | Won't fix (FALSE POSITIVE) | `keyMaterial`은 운영자 통제 + fetcher `maxBytes`로 hard-bound; 공격자 증폭 없음 — 선택적 cosmetic re-assert만 |
|
|
170
|
+
| CR2-010 | (보고된 P2) 두 NON-JSON SSE frame에 걸쳐 분할된 secret 미포착 | Accepted (documented) | round-1 `P1-CR-005`, `threat-model.md`, in-code comment에 이미 범위 외; JSON delta 채널은 `maxMatchBytes`까지 buffering함 |
|
|
171
|
+
|
|
154
172
|
## 6. P2 제품/문서 리스크 상태
|
|
155
173
|
|
|
156
174
|
| ID | 기존 리스크 | 상태 | 해소 증거 |
|
|
@@ -164,7 +182,7 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
164
182
|
|
|
165
183
|
이 체크리스트는 `1.x` stable 라인의 모든 릴리스에 대한 상시 배포 전 템플릿이며, `0.3.2` developer preview에서 처음 적용되었습니다. 그 결과를 아래에 참조 기록으로 보존합니다.
|
|
166
184
|
|
|
167
|
-
2026-06-16 현재 상태: G9은 `Pass`입니다
|
|
185
|
+
2026-06-16 현재 상태: G9은 `Pass`입니다(round-1 보완이 `haechi@1.3.1`로 발행됨). 게이트 **G10**(CR2, §5.8)은 이제 `Pass`입니다 — CR2 P2 + P3 묶음(`CR2-001..008`)이 Resolved이며 `haechi@1.3.2`로 발행되었으므로, 그 컷에 대해 이 체크리스트가 해제되었습니다.
|
|
168
186
|
|
|
169
187
|
외부 npm 게이트 확인 결과(`0.3.2` developer preview, 2026-06-10, 배포 후)는 다음과 같습니다.
|
|
170
188
|
|
|
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
14
14
|
| Category | Judgment | Rationale |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
17
|
-
| GitHub release/tag | Allowed (`v1.3.
|
|
18
|
-
| npm stable | `haechi@1.3.
|
|
19
|
-
| Production use | Operator-gated; upgrade to `1.3.
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.3.2` released) | The `v1.3.2` CR2 remediation cut is tagged and released; all §5.7 and §5.8 (`CR2-001..008`) findings are Resolved and G9/G10 are Pass |
|
|
18
|
+
| npm stable | `haechi@1.3.2` published | The CR2 remediation shipped in the `haechi@1.3.2` attested OIDC publish (2026-06-16); the prior `1.3.1` carries the pre-CR2-fix behavior |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.3.2` | Supported only with operator network controls, authz/authn, and key custody; operators on `haechi@1.3.1` should upgrade to `1.3.2` to pick up the CR2 fixes (notably the `CR2-001` proxy upstream-cancel and `CR2-002` token-vault audit hygiene) before routing sensitive third-party upstream traffic through the proxy |
|
|
20
20
|
|
|
21
21
|
## 2. Release Gates
|
|
22
22
|
|
|
@@ -32,6 +32,7 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
32
32
|
| G7 | 1.2.0 Reliability Hardening Track (WS1–WS6) | Detection quality measured + tightened (WS2: a labeled-corpus precision/recall `bench:detection` gate, credential + international-PII coverage, `filters.minConfidence` / `filters.allowlist` with the hard-block-types invariant, NFKC unicode-evasion folding with offset-integrity); WS3 injectable `rateLimiter` seam + bounded fixed-window map; WS4 operability (`/__haechi/live`+`/ready` split, injectable `/metrics`, structured logs + per-request `correlationId`, graceful drain, max-in-flight backpressure, env overlay, hardened Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind hardening (`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST control-mapping whitepaper + RFC 9116 `security.txt` + vulnerability-disclosure path. Every change is additive behind 1.1-preserving defaults (`tests/api-contract.test.mjs` green); the no-plaintext-in-audit invariant extends to telemetry; core stays zero runtime dependency; core bumped to 1.2.0 (additive minor) | Pass |
|
|
33
33
|
| G8 | 1.3.0 backend + detection coverage expansion | New protocol adapters for the **Anthropic Messages API** (`/v1/messages`, content-block + SSE `delta.text` with `event:`-line-preserving re-serialize) and the **Google Gemini API** (model-in-path `:generateContent`/`:streamGenerateContent` via an additive `:method`-suffix route matcher that leaves the exact-match adapters byte-identical); detection coverage expansion — cloud/SaaS provider keys (OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored) and international PII (FR/ES/JP + IT/SG/IN/DE/NL national IDs with checksum validators), each hard-block-vs-dial-eligible decision driven by measured collision rates (a non-numeric anchor or implausibly-rare shape is required for hard-block; a bare-digit run over a common length stays allowlist-clearable); a `bench:throughput` proxy load benchmark; the `haechi-ratelimit-redis` shared-store rate-limiter satellite (the WS3 seam's production consumer; the proxy now `await`s `rateLimiter.allow`); `haechi-dashboard` surfaces the per-request `correlationId`. Every change is additive — new `target.type`/detection-type/`privacy.profile` *values*, not new config keys (`configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped to 1.3.0 (additive minor) | Pass |
|
|
34
34
|
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
|
+
| G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
35
36
|
|
|
36
37
|
## 3. P0 Distribution-Blocking Risk Status
|
|
37
38
|
|
|
@@ -159,6 +160,23 @@ The authoritative itemized register is `docs/current/code-review-risk-register-2
|
|
|
159
160
|
| P2-CR-012 | KMS vault IPv6 loopback carve-out lacks IPv6-focused tests | Resolved | `satellites/crypto-kms/vault.test.mjs` adds a dedicated IPv6 loopback policy test ("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012") covering bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, and hex `::ffff:7f00:1`/`::ffff:7f00:0001` (plus bracketed variants), and asserts a public mapped address (`::ffff:8.8.8.8`/`::ffff:808:808`) is NOT over-blocked; the extended range table and `ssrf-parity.test.mjs` lock the dotted+hex agreement with auth-jwt |
|
|
160
161
|
| P2-CR-013 | SSE multi-line `data:` fields are joined without newline separators | Resolved | `parseFrame` joins multiple `data:` lines with `join("\n")` (spec separator) and strips only the single spec leading space per line; multi-line JSON still `JSON.parse`s, multi-line plain text is reconstructed with newlines for inspection, and `serializeTextFrame` re-emits a multi-line payload as multiple `data:` lines; `tests/stream-filter.test.mjs` covers a multi-line JSON event and a multi-line plain-text event with PII |
|
|
161
162
|
|
|
163
|
+
## 5.8 2026-06-16 Code Review Round 2 (CR2) Status — gate G10
|
|
164
|
+
|
|
165
|
+
The authoritative itemized register is `docs/current/code-review-risk-register-2026-06-16-round2.md`; this is the release-gate summary. A second deep review after the 1.3.1 cut found **no P0 and no P1** (the two externally-reported P1s both verified down to P2 — neither is a stored-plaintext leak or an auth/SSRF bypass). The three P2s + the P3 cluster (`CR2-001..008`) are **Resolved and shipped in `haechi@1.3.2`**; one reported item was a **false positive** (`CR2-009`, won't-fix) and one is an **already-documented accepted residual** (`CR2-010`, accepted). **G10 is Pass.**
|
|
166
|
+
|
|
167
|
+
| ID | Risk | Status | Required closure evidence |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| CR2-001 | Pass-through streaming never cancels the upstream reader on downstream disconnect (`pipeUpstreamBodyBounded` parks on `drain` forever) — unauthenticated resource leak | Resolved | Per-request `AbortController` + client `close`/`aborted` listener that cancels the upstream reader and aborts the fetch; `drain` wait raced against `close`; regression test that a mid-stream disconnect cancels the reader promptly |
|
|
170
|
+
| CR2-002 | Token-vault reveal/purge writes the raw caller-supplied `token` + `error.message` (token-interpolated) into the audit event; `FORBIDDEN_KEYS` strips by key name only | Resolved | Generic error messages; keyed-HMAC or `tok_`-shape-validate the `token` before recording; enum `reasonCode` instead of `error.message`; regression test that no raw token reaches `reason`/`token`; reconcile the invariant wording |
|
|
171
|
+
| CR2-003 | Plugin IPC reply not size-bounded before `JSON.parse`; process child has no heap cap → event-loop stall + memory spike from a hostile signed plugin | Resolved | Reply byte-length check before parse in both sandboxes (drop oversized as deny); `--max-old-space-size` heap cap on the process child via a new `resourceLimits` knob; regression test with an oversized-reply fixture |
|
|
172
|
+
| CR2-004 | `sanitizeResponseHeaders` keeps stale body-coupled validators (`etag`/`content-md5`/`digest`/`last-modified`) on a transformed response | Resolved | Drop those headers on every body-mutating path + `cache-control: no-store`; test that a mutated response drops the upstream `ETag` |
|
|
173
|
+
| CR2-005 | Over-`maxBytes` request body is read-and-discarded until the (finite) Node `requestTimeout` — no socket teardown | Resolved | `request.pause()`/`destroy()` (or `Connection: close`) on the 413 path; optionally non-null default timeouts |
|
|
174
|
+
| CR2-006 | `mcp-wrap --stderr filter` is line-oriented, so a newline-split secret evades it (inherent; single-line secrets caught, `drop` available) | Resolved | `COMMAND_HELP` + register note; recommend `--stderr drop` for high-sensitivity tools |
|
|
175
|
+
| CR2-007 | README says mcp-wrap "stderr ... pass through" but the default is now `--stderr filter` | Resolved | Correct README + `README.ko.md` |
|
|
176
|
+
| CR2-008 | README streaming split-match claim is unscoped (cross-frame buffering is delta-channel only) | Resolved | Scope both README passages + `README.ko.md` to the delta channel |
|
|
177
|
+
| CR2-009 | (reported P2) `keyMaterial` appended after the credential `maxMessageBytes` check | Won't fix (FALSE POSITIVE) | `keyMaterial` is operator-controlled + hard-bounded by the fetcher `maxBytes`; no attacker amplification — optional cosmetic re-assert only |
|
|
178
|
+
| CR2-010 | (reported P2) secret split across two NON-JSON SSE frames not caught | Accepted (documented) | Already out-of-scope in round-1 `P1-CR-005`, `threat-model.md`, and an in-code comment; the JSON delta channel does buffer up to `maxMatchBytes` |
|
|
179
|
+
|
|
162
180
|
## 6. P2 Product/Documentation Risk Status
|
|
163
181
|
|
|
164
182
|
| ID | Risk | Status | Resolution evidence |
|
|
@@ -172,7 +190,7 @@ The authoritative itemized register is `docs/current/code-review-risk-register-2
|
|
|
172
190
|
|
|
173
191
|
This checklist is the standing pre-distribution template for every release on the `1.x` stable line; it was first exercised for the `0.3.2` developer preview, whose results are retained below as the reference record.
|
|
174
192
|
|
|
175
|
-
Current 2026-06-16 status: G9 is `Pass` — the
|
|
193
|
+
Current 2026-06-16 status: G9 is `Pass` (round-1 remediation shipped in `haechi@1.3.1`). Gate **G10** (CR2, §5.8) is now `Pass` — the CR2 P2s + P3 cluster (`CR2-001..008`) are Resolved and shipped in `haechi@1.3.2`, so the checklist is cleared for that cut.
|
|
176
194
|
|
|
177
195
|
External npm gate check results (`0.3.2` developer preview, 2026-06-10, post-publish):
|
|
178
196
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
],
|
|
67
67
|
"scripts": {
|
|
68
68
|
"test": "node --test",
|
|
69
|
+
"test:inference:live": "node --test tests/local-inference.integration.test.mjs",
|
|
69
70
|
"check:types": "tsc -p jsconfig.json --noEmit",
|
|
70
71
|
"pack:dry": "npm pack --dry-run",
|
|
71
72
|
"scan:stale-names": "node scripts/stale-name-scan.mjs",
|
|
@@ -766,7 +766,7 @@ const COMMAND_HELP = {
|
|
|
766
766
|
"mcp-wrap": {
|
|
767
767
|
usage: "haechi mcp-wrap [--config haechi.config.json] [--stderr filter|drop|inherit] -- <command> [args...]",
|
|
768
768
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
769
|
-
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs. --stderr controls the child's stderr: filter (default) protects each line with the same policy before re-emitting, drop discards it, inherit passes it through raw (an explicit, opt-in local-process boundary). filter follows the configured policy mode — in dry-run/report-only it detects but does not transform (like the rest of the pipeline), so set policy.mode=enforce for stderr redaction to take effect."
|
|
769
|
+
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs. --stderr controls the child's stderr: filter (default) protects each line with the same policy before re-emitting, drop discards it, inherit passes it through raw (an explicit, opt-in local-process boundary). filter follows the configured policy mode — in dry-run/report-only it detects but does not transform (like the rest of the pipeline), so set policy.mode=enforce for stderr redaction to take effect. filter protects each COMPLETE line independently, so it cannot catch a secret a child deliberately splits across a newline; use drop for high-sensitivity tools."
|
|
770
770
|
},
|
|
771
771
|
auth: {
|
|
772
772
|
usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -1022,7 +1022,11 @@ function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
|
1022
1022
|
return createProcessIsolatedAuthProviderSync({
|
|
1023
1023
|
...common,
|
|
1024
1024
|
netEnforcement: plugin.netEnforcement ?? "require-permission",
|
|
1025
|
-
keyMaterial: plugin.keyMaterial ?? null
|
|
1025
|
+
keyMaterial: plugin.keyMaterial ?? null,
|
|
1026
|
+
// CR2-003: reuse the worker's resourceLimits.maxOldGenerationSizeMb knob to
|
|
1027
|
+
// cap the child heap. Optional for the process runtime (the sandbox defaults
|
|
1028
|
+
// when absent), so pass it through whether or not the config supplied it.
|
|
1029
|
+
resourceLimits: plugin.resourceLimits ?? null
|
|
1026
1030
|
});
|
|
1027
1031
|
}
|
|
1028
1032
|
return createSandboxedAuthProviderSync({ ...common, resourceLimits: plugin.resourceLimits });
|
|
@@ -44,8 +44,19 @@ import {
|
|
|
44
44
|
// The child flags. `--permission` enables the deny-by-default Node permission
|
|
45
45
|
// model; we pass NO --allow-* grant, so fs/child-process/worker/addons/wasi/net
|
|
46
46
|
// are all kernel-denied. `--disable-proto=delete` removes Object.prototype.__proto__.
|
|
47
|
+
// A `--max-old-space-size=<mb>` heap cap is appended PER-SPAWN (see spawnAndLoad):
|
|
48
|
+
// unlike the worker (resourceLimits OOMs a runaway), a process child has NO heap
|
|
49
|
+
// cap by default, so a hostile/buggy signed plugin could build a reply up to the
|
|
50
|
+
// child's default V8 heap. The cap bounds the child; the host-side reply-size bound
|
|
51
|
+
// (CR2-003) bounds the host regardless.
|
|
47
52
|
const CHILD_FLAGS = Object.freeze(["--permission", "--disable-proto=delete"]);
|
|
48
53
|
|
|
54
|
+
// Default child heap cap (MB) when a process-runtime config does not supply
|
|
55
|
+
// resourceLimits.maxOldGenerationSizeMb. Non-breaking: the worker REQUIRES the
|
|
56
|
+
// knob, but the process runtime defaults rather than throwing so an isolation:
|
|
57
|
+
// process config without resourceLimits keeps working.
|
|
58
|
+
const DEFAULT_MAX_OLD_GEN_MB = 128;
|
|
59
|
+
|
|
49
60
|
// A CONSTANT bootstrap harness, passed via `node -e`. It is identical for every
|
|
50
61
|
// plugin (the plugin bytes arrive over IPC, NOT on the command line — so there is
|
|
51
62
|
// no ARG_MAX limit and the harness never varies). It runs as CommonJS under -e and
|
|
@@ -155,6 +166,10 @@ function createProcessIsolatedAuthProviderHandle({
|
|
|
155
166
|
timeoutMs,
|
|
156
167
|
maxPendingCalls = 8,
|
|
157
168
|
maxMessageBytes = 16384,
|
|
169
|
+
// Child V8 heap cap. Reuses the worker's resourceLimits.maxOldGenerationSizeMb
|
|
170
|
+
// knob (CR2-003). Optional for the process runtime: a config that omits it falls
|
|
171
|
+
// back to DEFAULT_MAX_OLD_GEN_MB rather than throwing (non-breaking).
|
|
172
|
+
resourceLimits = null,
|
|
158
173
|
coreVersion = null,
|
|
159
174
|
now = Date.now,
|
|
160
175
|
allowedLabelKeys,
|
|
@@ -201,6 +216,21 @@ function createProcessIsolatedAuthProviderHandle({
|
|
|
201
216
|
if (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1) {
|
|
202
217
|
throw new Error("maxMessageBytes must be a positive integer");
|
|
203
218
|
}
|
|
219
|
+
// Resolve the child heap cap (MB). Optional for the process runtime; if supplied
|
|
220
|
+
// it must be a positive-integer maxOldGenerationSizeMb (same shape as the worker),
|
|
221
|
+
// else default to DEFAULT_MAX_OLD_GEN_MB (non-breaking — never throws on absence).
|
|
222
|
+
let maxOldGenerationSizeMb = DEFAULT_MAX_OLD_GEN_MB;
|
|
223
|
+
if (resourceLimits !== null && resourceLimits !== undefined) {
|
|
224
|
+
if (typeof resourceLimits !== "object" || Array.isArray(resourceLimits)) {
|
|
225
|
+
throw new Error("createProcessIsolatedAuthProvider resourceLimits must be an object");
|
|
226
|
+
}
|
|
227
|
+
if (resourceLimits.maxOldGenerationSizeMb !== undefined) {
|
|
228
|
+
if (!Number.isInteger(resourceLimits.maxOldGenerationSizeMb) || resourceLimits.maxOldGenerationSizeMb <= 0) {
|
|
229
|
+
throw new Error("createProcessIsolatedAuthProvider resourceLimits.maxOldGenerationSizeMb must be a positive integer");
|
|
230
|
+
}
|
|
231
|
+
maxOldGenerationSizeMb = resourceLimits.maxOldGenerationSizeMb;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
204
234
|
// Fail-closed network containment. PR1 supports only the "require-permission"
|
|
205
235
|
// mode; if this Node cannot enforce --allow-net, refuse to construct rather than
|
|
206
236
|
// run a plugin whose network egress is uncontained.
|
|
@@ -273,7 +303,11 @@ function createProcessIsolatedAuthProviderHandle({
|
|
|
273
303
|
// any failure kills the child and throws → fail closed. NOTE the plugin source
|
|
274
304
|
// crosses over IPC (not the command line) so there is no ARG_MAX limit.
|
|
275
305
|
async function spawnAndLoad({ entrySource, pluginId: pid }) {
|
|
276
|
-
|
|
306
|
+
// Build the spawn args by spreading the frozen base flags + the per-spawn heap
|
|
307
|
+
// cap. `--max-old-space-size` composes with `--permission`/`--disable-proto=
|
|
308
|
+
// delete` and the data:-URL load (verified). The cap bounds a runaway child;
|
|
309
|
+
// the host-side reply-size bound bounds the host regardless of the child heap.
|
|
310
|
+
const c = spawn(execPath, [...CHILD_FLAGS, `--max-old-space-size=${maxOldGenerationSizeMb}`, "-e", PROCESS_HARNESS], {
|
|
277
311
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
278
312
|
serialization: "json",
|
|
279
313
|
env: scrubbedEnv(),
|
|
@@ -291,6 +325,27 @@ function createProcessIsolatedAuthProviderHandle({
|
|
|
291
325
|
const failed = new Promise((_, reject) => { onFail = reject; });
|
|
292
326
|
|
|
293
327
|
c.on("message", (raw) => {
|
|
328
|
+
// REPLY SIZE BOUND (CR2-003): bound host-side work BEFORE JSON.parse. Unlike
|
|
329
|
+
// the worker (resourceLimits OOMs a runaway), the child has only the
|
|
330
|
+
// --max-old-space-size cap, so it can still build a reply up to that heap and
|
|
331
|
+
// process.send it; a synchronous JSON.parse of a multi-MB string stalls the
|
|
332
|
+
// host event loop (the per-call timeout cannot fire mid-parse). The reply is a
|
|
333
|
+
// STRING (serialization:'json'); measure its byte length and, if it exceeds the
|
|
334
|
+
// SAME maxMessageBytes ceiling the outbound credential obeys, drop the frame as
|
|
335
|
+
// an oversized DENY WITHOUT parsing. The auth reply is the only attacker-sized
|
|
336
|
+
// frame (claims come from the plugin); the tiny ready/loaded/load-error control
|
|
337
|
+
// frames are always far under the ceiling, so the uniform bound never harms the
|
|
338
|
+
// handshake. Single-occupancy: settle the one live pending call as oversized.
|
|
339
|
+
const replyBytes = typeof raw === "string"
|
|
340
|
+
? Buffer.byteLength(raw, "utf8")
|
|
341
|
+
: Buffer.byteLength(String(raw), "utf8");
|
|
342
|
+
if (replyBytes > maxMessageBytes) {
|
|
343
|
+
for (const [cid, settle] of pending) {
|
|
344
|
+
pending.delete(cid);
|
|
345
|
+
settle({ __oversized: true });
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
294
349
|
let parsed;
|
|
295
350
|
try {
|
|
296
351
|
parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
|
@@ -150,6 +150,29 @@ function createSandboxedAuthProviderHandle({
|
|
|
150
150
|
workerData: {}
|
|
151
151
|
});
|
|
152
152
|
w.on("message", (raw) => {
|
|
153
|
+
// REPLY SIZE BOUND (CR2-003): bound host-side work BEFORE JSON.parse. The
|
|
154
|
+
// worker has an implicit heap cap (resourceLimits), but enforce the same
|
|
155
|
+
// maxMessageBytes ceiling on the INBOUND plugin→host reply that the OUTBOUND
|
|
156
|
+
// host→plugin credential message obeys — a hostile/buggy plugin can build a
|
|
157
|
+
// multi-MB reply and a synchronous JSON.parse would stall the host event loop
|
|
158
|
+
// (the per-call timeout cannot fire mid-parse). The reply is a STRING posted
|
|
159
|
+
// via JSON.stringify; measure its byte length and, if oversized, settle the
|
|
160
|
+
// matched call as an oversized DENY (mirroring the credential deny) WITHOUT
|
|
161
|
+
// parsing. We must locate the pending settle WITHOUT parsing the cid, so an
|
|
162
|
+
// oversized reply settles the single live pending call (single-occupancy: at
|
|
163
|
+
// most one entry is ever live).
|
|
164
|
+
const replyBytes = typeof raw === "string"
|
|
165
|
+
? Buffer.byteLength(raw, "utf8")
|
|
166
|
+
: Buffer.byteLength(String(raw), "utf8");
|
|
167
|
+
if (replyBytes > maxMessageBytes) {
|
|
168
|
+
// Single-occupancy: settle the one live pending call as oversized, never
|
|
169
|
+
// touching JSON.parse on the oversized payload.
|
|
170
|
+
for (const [cid, settle] of pending) {
|
|
171
|
+
pending.delete(cid);
|
|
172
|
+
settle({ __oversized: true });
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
153
176
|
let parsed;
|
|
154
177
|
try {
|
|
155
178
|
parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
package/packages/proxy/index.mjs
CHANGED
|
@@ -227,7 +227,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
227
227
|
const authContext = { identity, profile, policyEngine, correlationId };
|
|
228
228
|
|
|
229
229
|
const body = await readBody(request, {
|
|
230
|
-
maxBytes: config.limits.maxRequestBytes
|
|
230
|
+
maxBytes: config.limits.maxRequestBytes,
|
|
231
|
+
response
|
|
231
232
|
});
|
|
232
233
|
const json = parseJsonBody(body);
|
|
233
234
|
|
|
@@ -268,13 +269,18 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
268
269
|
blocked: false
|
|
269
270
|
});
|
|
270
271
|
countDecision(metrics, { routeContext, mode, decision: "forwarded" });
|
|
272
|
+
// CR2-001 — a per-request AbortController whose signal is threaded into
|
|
273
|
+
// the upstream fetch; aborting it (on a downstream client disconnect)
|
|
274
|
+
// tears down the upstream request + body so neither leaks.
|
|
275
|
+
const streamAbort = new AbortController();
|
|
271
276
|
const upstreamResponse = await forward({
|
|
272
277
|
upstream: config.target.upstream,
|
|
273
278
|
request,
|
|
274
279
|
body,
|
|
275
280
|
timeoutMs: config.limits.upstreamTimeoutMs,
|
|
276
281
|
metrics,
|
|
277
|
-
forwardPolicy
|
|
282
|
+
forwardPolicy,
|
|
283
|
+
abortController: streamAbort
|
|
278
284
|
});
|
|
279
285
|
// P1-CR-003 — sanitize response headers (strip the upstream's
|
|
280
286
|
// content-encoding/content-length/transfer/hop-by-hop) on this path
|
|
@@ -286,7 +292,9 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
286
292
|
await pipeUpstreamBodyBounded({
|
|
287
293
|
upstreamResponse,
|
|
288
294
|
response,
|
|
295
|
+
request,
|
|
289
296
|
maxBytes: streamingPassThroughMaxBytes(config),
|
|
297
|
+
abortController: streamAbort,
|
|
290
298
|
logger,
|
|
291
299
|
metrics,
|
|
292
300
|
correlationId
|
|
@@ -360,10 +368,16 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
360
368
|
});
|
|
361
369
|
metrics.increment("haechi_internal_error_total");
|
|
362
370
|
}
|
|
371
|
+
// CR2-005 — an over-limit request body teardown carries `Connection: close`
|
|
372
|
+
// so the socket releases once the 413 is delivered (readBody destroys the
|
|
373
|
+
// request on response finish/close).
|
|
374
|
+
const extraHeaders = error?.errorCode === "haechi_request_body_too_large"
|
|
375
|
+
? { connection: "close" }
|
|
376
|
+
: null;
|
|
363
377
|
writeJson(response, error.statusCode ?? 500, {
|
|
364
378
|
error: error.errorCode ?? "haechi_proxy_error",
|
|
365
379
|
message: expected ? error.message : "Internal proxy error"
|
|
366
|
-
});
|
|
380
|
+
}, extraHeaders);
|
|
367
381
|
} finally {
|
|
368
382
|
const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9;
|
|
369
383
|
// route label is a bounded route id (or "unknown") — never an identity/value.
|
|
@@ -783,7 +797,7 @@ function streamingPassThroughMaxBytes(config) {
|
|
|
783
797
|
// the client response so a long-lived or malicious stream cannot hold memory or
|
|
784
798
|
// the connection open unbounded. Bytes already written cannot be retracted, so
|
|
785
799
|
// this caps total memory/throughput, not the already-flushed prefix.
|
|
786
|
-
async function pipeUpstreamBodyBounded({ upstreamResponse, response, maxBytes, logger = null, metrics = null, correlationId = null }) {
|
|
800
|
+
async function pipeUpstreamBodyBounded({ upstreamResponse, response, request = null, maxBytes, abortController = null, logger = null, metrics = null, correlationId = null }) {
|
|
787
801
|
if (!upstreamResponse.body) {
|
|
788
802
|
response.end();
|
|
789
803
|
return;
|
|
@@ -791,8 +805,50 @@ async function pipeUpstreamBodyBounded({ upstreamResponse, response, maxBytes, l
|
|
|
791
805
|
|
|
792
806
|
const reader = upstreamResponse.body.getReader();
|
|
793
807
|
let received = 0;
|
|
808
|
+
|
|
809
|
+
// CR2-001 — a ONE-SHOT teardown on a downstream client disconnect. Without it,
|
|
810
|
+
// a parked `await once(response, "drain")` (backpressure) or a parked
|
|
811
|
+
// `await reader.read()` (no backpressure, upstream idle) never unparks after the
|
|
812
|
+
// client socket dies — neither `drain` nor `error` fires — so the async task and
|
|
813
|
+
// the upstream connection leak. On `close`/`aborted` we cancel the upstream
|
|
814
|
+
// reader (interrupts a parked read) AND abort the upstream fetch (tears down the
|
|
815
|
+
// connection); the listeners are removed on normal completion so the happy path
|
|
816
|
+
// does not leak a handle.
|
|
817
|
+
let disconnected = false;
|
|
818
|
+
// A SINGLE promise resolved by the one-shot tearDown below, so the backpressure
|
|
819
|
+
// wait can race against the disconnect WITHOUT registering a fresh `close`
|
|
820
|
+
// listener every drain cycle (which would accumulate on a sustained
|
|
821
|
+
// backpressured stream and trip MaxListenersExceededWarning).
|
|
822
|
+
let signalDisconnected;
|
|
823
|
+
const disconnectedPromise = new Promise((resolve) => {
|
|
824
|
+
signalDisconnected = resolve;
|
|
825
|
+
});
|
|
826
|
+
const tearDown = () => {
|
|
827
|
+
if (disconnected) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
disconnected = true;
|
|
831
|
+
signalDisconnected();
|
|
832
|
+
void cancelReader(reader);
|
|
833
|
+
abortController?.abort();
|
|
834
|
+
};
|
|
835
|
+
const disconnectSources = [response, request].filter(Boolean);
|
|
836
|
+
for (const source of disconnectSources) {
|
|
837
|
+
source.once("close", tearDown);
|
|
838
|
+
source.once("aborted", tearDown);
|
|
839
|
+
}
|
|
840
|
+
const cleanupListeners = () => {
|
|
841
|
+
for (const source of disconnectSources) {
|
|
842
|
+
source.removeListener("close", tearDown);
|
|
843
|
+
source.removeListener("aborted", tearDown);
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
794
847
|
try {
|
|
795
848
|
while (true) {
|
|
849
|
+
if (disconnected) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
796
852
|
const { done, value } = await reader.read();
|
|
797
853
|
if (done) {
|
|
798
854
|
break;
|
|
@@ -802,6 +858,7 @@ async function pipeUpstreamBodyBounded({ upstreamResponse, response, maxBytes, l
|
|
|
802
858
|
// Over the cap: stop reading upstream and tear down the client write so
|
|
803
859
|
// the oversize stream is bounded (fail-closed on size).
|
|
804
860
|
void cancelReader(reader);
|
|
861
|
+
abortController?.abort();
|
|
805
862
|
metrics?.increment("haechi_response_stream_truncated_total");
|
|
806
863
|
logger?.error("proxy_stream_pass_through_too_large", {
|
|
807
864
|
correlationId,
|
|
@@ -813,18 +870,29 @@ async function pipeUpstreamBodyBounded({ upstreamResponse, response, maxBytes, l
|
|
|
813
870
|
return;
|
|
814
871
|
}
|
|
815
872
|
// Respect downstream backpressure: stop pulling upstream until the client
|
|
816
|
-
// socket has drained.
|
|
873
|
+
// socket has drained. CR2-001 — race the drain wait against `close` so a
|
|
874
|
+
// client disconnect mid-backpressure unparks the wait instead of hanging
|
|
875
|
+
// until the request timeout.
|
|
817
876
|
const ok = response.write(Buffer.from(value));
|
|
818
|
-
if (!ok) {
|
|
819
|
-
await
|
|
877
|
+
if (!ok && !disconnected) {
|
|
878
|
+
await Promise.race([
|
|
879
|
+
once(response, "drain"),
|
|
880
|
+
disconnectedPromise
|
|
881
|
+
]);
|
|
882
|
+
if (disconnected || response.writableEnded || response.destroyed) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
820
885
|
}
|
|
821
886
|
}
|
|
822
887
|
response.end();
|
|
823
888
|
} catch (error) {
|
|
824
889
|
void cancelReader(reader);
|
|
890
|
+
abortController?.abort();
|
|
825
891
|
if (!response.writableEnded) {
|
|
826
892
|
response.destroy();
|
|
827
893
|
}
|
|
894
|
+
} finally {
|
|
895
|
+
cleanupListeners();
|
|
828
896
|
}
|
|
829
897
|
}
|
|
830
898
|
|
|
@@ -1051,14 +1119,24 @@ function restoreTokens(value, tokenValues) {
|
|
|
1051
1119
|
return value;
|
|
1052
1120
|
}
|
|
1053
1121
|
|
|
1054
|
-
async function forward({ upstream, request, body, timeoutMs = null, metrics = null, forwardPolicy = {} }) {
|
|
1122
|
+
async function forward({ upstream, request, body, timeoutMs = null, metrics = null, forwardPolicy = {}, abortController = null }) {
|
|
1055
1123
|
const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
|
|
1124
|
+
// CR2-001 — combine the upstream timeout with a per-request AbortController so a
|
|
1125
|
+
// downstream client disconnect (which aborts `abortController`) tears down the
|
|
1126
|
+
// in-flight upstream fetch + its body, instead of leaking the connection.
|
|
1127
|
+
const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : null;
|
|
1128
|
+
let signal;
|
|
1129
|
+
if (abortController && timeoutSignal) {
|
|
1130
|
+
signal = AbortSignal.any([abortController.signal, timeoutSignal]);
|
|
1131
|
+
} else {
|
|
1132
|
+
signal = abortController ? abortController.signal : timeoutSignal ?? undefined;
|
|
1133
|
+
}
|
|
1056
1134
|
try {
|
|
1057
1135
|
return await fetch(target, {
|
|
1058
1136
|
method: request.method,
|
|
1059
1137
|
headers: filteredHeaders(request.headers, forwardPolicy),
|
|
1060
1138
|
body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
|
|
1061
|
-
signal
|
|
1139
|
+
signal
|
|
1062
1140
|
});
|
|
1063
1141
|
} catch (error) {
|
|
1064
1142
|
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
@@ -1195,7 +1273,7 @@ function appendHeader(target, key, value) {
|
|
|
1195
1273
|
}
|
|
1196
1274
|
}
|
|
1197
1275
|
|
|
1198
|
-
function readBody(request, { maxBytes }) {
|
|
1276
|
+
function readBody(request, { maxBytes, response = null }) {
|
|
1199
1277
|
return new Promise((resolve, reject) => {
|
|
1200
1278
|
const chunks = [];
|
|
1201
1279
|
let received = 0;
|
|
@@ -1208,6 +1286,26 @@ function readBody(request, { maxBytes }) {
|
|
|
1208
1286
|
received += chunk.byteLength;
|
|
1209
1287
|
if (received > maxBytes) {
|
|
1210
1288
|
rejected = true;
|
|
1289
|
+
// CR2-005 — stop reading and release the socket PROMPTLY instead of
|
|
1290
|
+
// reading-and-discarding the rest of the upload until Node's finite
|
|
1291
|
+
// requestTimeout. pause() halts the flowing read immediately (no further
|
|
1292
|
+
// data is consumed); the connection is then torn down — but only AFTER the
|
|
1293
|
+
// 413 has been written, so the client still receives it. The 413 carries
|
|
1294
|
+
// `Connection: close` and the socket is destroyed once the response
|
|
1295
|
+
// finishes/closes (destroying before the response is sent would reset the
|
|
1296
|
+
// socket and the client would get a transport error instead of the 413).
|
|
1297
|
+
request.pause();
|
|
1298
|
+
if (response) {
|
|
1299
|
+
const destroyRequest = () => {
|
|
1300
|
+
if (!request.destroyed) {
|
|
1301
|
+
request.destroy();
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
response.once("finish", destroyRequest);
|
|
1305
|
+
response.once("close", destroyRequest);
|
|
1306
|
+
} else {
|
|
1307
|
+
request.destroy();
|
|
1308
|
+
}
|
|
1211
1309
|
reject(proxyError({
|
|
1212
1310
|
statusCode: 413,
|
|
1213
1311
|
errorCode: "haechi_request_body_too_large",
|
|
@@ -1260,8 +1358,8 @@ function parseJsonBody(body) {
|
|
|
1260
1358
|
}
|
|
1261
1359
|
}
|
|
1262
1360
|
|
|
1263
|
-
function writeJson(response, status, body) {
|
|
1264
|
-
response.writeHead(status, { "content-type": "application/json" });
|
|
1361
|
+
function writeJson(response, status, body, extraHeaders = null) {
|
|
1362
|
+
response.writeHead(status, { "content-type": "application/json", ...(extraHeaders ?? {}) });
|
|
1265
1363
|
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
1266
1364
|
}
|
|
1267
1365
|
|
|
@@ -1269,6 +1367,17 @@ function isJson(contentType = "") {
|
|
|
1269
1367
|
return contentType.toLowerCase().includes("application/json");
|
|
1270
1368
|
}
|
|
1271
1369
|
|
|
1370
|
+
// CR2-004 — body-coupled validator headers that describe the UPSTREAM body. On a
|
|
1371
|
+
// transformed (protected/redacted/re-serialized) response the body changed, so
|
|
1372
|
+
// these become stale and must be dropped (a client/proxy honoring the upstream's
|
|
1373
|
+
// etag/last-modified could otherwise serve or revalidate against the wrong body).
|
|
1374
|
+
const BODY_COUPLED_VALIDATOR_HEADERS = [
|
|
1375
|
+
"etag",
|
|
1376
|
+
"content-md5",
|
|
1377
|
+
"digest",
|
|
1378
|
+
"last-modified"
|
|
1379
|
+
];
|
|
1380
|
+
|
|
1272
1381
|
function transformedJsonHeaders(headers) {
|
|
1273
1382
|
// P1-CR-003 — defensively strip the full hop-by-hop/compression set (the
|
|
1274
1383
|
// caller already passes the sanitized headers, but the transformed JSON body
|
|
@@ -1277,6 +1386,13 @@ function transformedJsonHeaders(headers) {
|
|
|
1277
1386
|
for (const name of RESPONSE_HOP_BY_HOP_HEADERS) {
|
|
1278
1387
|
delete next[name];
|
|
1279
1388
|
}
|
|
1389
|
+
// CR2-004 — the body was MUTATED, so drop validators coupled to the upstream
|
|
1390
|
+
// body and forbid caching the rewritten response. This path only (the raw
|
|
1391
|
+
// pass-through path keeps its etag — its body is byte-unchanged so still valid).
|
|
1392
|
+
for (const name of BODY_COUPLED_VALIDATOR_HEADERS) {
|
|
1393
|
+
delete next[name];
|
|
1394
|
+
}
|
|
1395
|
+
next["cache-control"] = "no-store";
|
|
1280
1396
|
return next;
|
|
1281
1397
|
}
|
|
1282
1398
|
|
|
@@ -4,6 +4,12 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
|
|
6
6
|
const DETERMINISTIC_DOMAIN = "haechi:token-vault:deterministic:v1";
|
|
7
|
+
const AUDIT_ID_DOMAIN = "haechi:token-vault:audit-id:v1";
|
|
8
|
+
|
|
9
|
+
// Opaque vault token ids are `tok_<type>_<hexhash>` (random: 16 hex via
|
|
10
|
+
// shortHash; deterministic: 32 hex from hmac). Anything that does not match
|
|
11
|
+
// this shape is treated as a misused raw value and never written verbatim.
|
|
12
|
+
const VAULT_TOKEN_SHAPE = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
|
|
7
13
|
|
|
8
14
|
export function createLocalTokenVault({
|
|
9
15
|
path,
|
|
@@ -41,6 +47,30 @@ export function createLocalTokenVault({
|
|
|
41
47
|
return mutation;
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// The audit `token` field must never carry a raw secret. A legitimate token
|
|
51
|
+
// id is a non-sensitive opaque `tok_<type>_<hexhash>` — recorded verbatim for
|
|
52
|
+
// correlation. A caller who misuses the API and passes a raw value where a
|
|
53
|
+
// token id is expected would otherwise leak that value into the hash-chained
|
|
54
|
+
// log (sanitizeAudit strips by key name only). For non-matching inputs we
|
|
55
|
+
// record a keyed-HMAC under a dedicated domain, or a fixed redaction marker
|
|
56
|
+
// if no hmac is available — never the raw value.
|
|
57
|
+
async function safeAuditToken(token) {
|
|
58
|
+
if (token == null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof token === "string" && VAULT_TOKEN_SHAPE.test(token)) {
|
|
62
|
+
return token;
|
|
63
|
+
}
|
|
64
|
+
if (typeof cryptoProvider.hmac === "function") {
|
|
65
|
+
const digest = await cryptoProvider.hmac({
|
|
66
|
+
data: typeof token === "string" ? token : String(token),
|
|
67
|
+
domain: AUDIT_ID_DOMAIN
|
|
68
|
+
});
|
|
69
|
+
return `nontoken_${digest.slice(0, 32)}`;
|
|
70
|
+
}
|
|
71
|
+
return "[REDACTED:non-token]";
|
|
72
|
+
}
|
|
73
|
+
|
|
44
74
|
// Reveal/purge governance events must be auditable. Events carry token ids
|
|
45
75
|
// and decision metadata only — never plaintext values.
|
|
46
76
|
async function recordVaultEvent({ operation, decision, token = null, tokenType = null, reason = null, count = null }) {
|
|
@@ -58,7 +88,7 @@ export function createLocalTokenVault({
|
|
|
58
88
|
blocked: decision.endsWith("_denied"),
|
|
59
89
|
decision,
|
|
60
90
|
reason,
|
|
61
|
-
token,
|
|
91
|
+
token: await safeAuditToken(token),
|
|
62
92
|
tokenType,
|
|
63
93
|
count,
|
|
64
94
|
revealPolicy,
|
|
@@ -132,17 +162,28 @@ export function createLocalTokenVault({
|
|
|
132
162
|
});
|
|
133
163
|
throw new Error("Token reveal is disabled by tokenVault.revealPolicy");
|
|
134
164
|
}
|
|
165
|
+
// Failure branches carry a stable reasonCode (never error.message / raw
|
|
166
|
+
// token); the message itself never interpolates the token argument.
|
|
167
|
+
let reasonCode = "reveal_error";
|
|
135
168
|
try {
|
|
136
169
|
const vault = await readVault(path);
|
|
137
170
|
const record = vault.tokens[token];
|
|
138
171
|
if (!record) {
|
|
139
|
-
|
|
172
|
+
reasonCode = "unknown_token";
|
|
173
|
+
throw new Error("Unknown token");
|
|
140
174
|
}
|
|
141
175
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) {
|
|
142
|
-
|
|
176
|
+
reasonCode = "token_expired";
|
|
177
|
+
throw new Error("Token expired");
|
|
143
178
|
}
|
|
144
179
|
const aad = context ? { ...record.aad, context } : record.aad;
|
|
145
|
-
|
|
180
|
+
let plaintext;
|
|
181
|
+
try {
|
|
182
|
+
plaintext = await cryptoProvider.decrypt({ envelope: record.envelope, aad });
|
|
183
|
+
} catch {
|
|
184
|
+
reasonCode = "decrypt_failed";
|
|
185
|
+
throw new Error("Token decrypt failed");
|
|
186
|
+
}
|
|
146
187
|
await recordVaultEvent({
|
|
147
188
|
operation: "token-vault:reveal",
|
|
148
189
|
decision: "reveal_allowed",
|
|
@@ -159,7 +200,7 @@ export function createLocalTokenVault({
|
|
|
159
200
|
operation: "token-vault:reveal",
|
|
160
201
|
decision: "reveal_failed",
|
|
161
202
|
token,
|
|
162
|
-
reason:
|
|
203
|
+
reason: reasonCode
|
|
163
204
|
});
|
|
164
205
|
throw error;
|
|
165
206
|
}
|