haechi 1.3.1 → 1.3.3
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 +32 -1
- package/docs/current/operations-runbook.md +39 -1
- package/docs/current/release-process.ko.md +15 -6
- package/docs/current/release-process.md +15 -6
- package/docs/current/reliability-hardening-track.ko.md +1 -1
- package/docs/current/reliability-hardening-track.md +1 -1
- 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/filter/index.mjs +155 -7
- 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/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
|
}
|