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.
@@ -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 once(response, "drain");
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: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
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
- throw new Error(`Unknown token: ${token}`);
172
+ reasonCode = "unknown_token";
173
+ throw new Error("Unknown token");
140
174
  }
141
175
  if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) {
142
- throw new Error(`Token expired: ${token}`);
176
+ reasonCode = "token_expired";
177
+ throw new Error("Token expired");
143
178
  }
144
179
  const aad = context ? { ...record.aad, context } : record.aad;
145
- const plaintext = await cryptoProvider.decrypt({ envelope: record.envelope, aad });
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: error.message
203
+ reason: reasonCode
163
204
  });
164
205
  throw error;
165
206
  }