haechi 1.3.0 → 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 +15 -4
- package/README.md +15 -4
- 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/code-review-risk-register-2026-06-16.ko.md +377 -0
- package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
- package/docs/current/configuration.ko.md +2 -1
- package/docs/current/configuration.md +2 -1
- 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 +48 -5
- package/docs/current/risk-register-release-gate.md +48 -5
- package/docs/current/shared-responsibility.ko.md +10 -1
- package/docs/current/shared-responsibility.md +10 -1
- package/docs/current/threat-model.ko.md +3 -0
- package/docs/current/threat-model.md +3 -0
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +92 -3
- package/packages/cli/runtime.mjs +54 -1
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/plugin/process-sandbox.mjs +56 -1
- package/packages/plugin/sandbox.mjs +23 -0
- package/packages/proxy/index.mjs +385 -34
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +127 -12
- package/packages/token-vault/index.mjs +46 -5
|
@@ -4,6 +4,37 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
const ALG = "AES-256-GCM";
|
|
6
6
|
|
|
7
|
+
// Single source of truth for parsing + validating an on-disk local key file.
|
|
8
|
+
// Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
|
|
9
|
+
// go through here so the 32-byte key invariant is enforced once. Throws a
|
|
10
|
+
// specific error per defect so a corrupted-but-present file is caught at init
|
|
11
|
+
// time instead of failing later during encrypt/decrypt/token/bundle.
|
|
12
|
+
//
|
|
13
|
+
// requireActive: init demands an explicit status:"active" key; the provider
|
|
14
|
+
// keeps its historical fallback to keys[0] when none is marked active.
|
|
15
|
+
async function loadKeyFile(keyFile, { requireActive = false } = {}) {
|
|
16
|
+
const raw = JSON.parse(await readFile(keyFile, "utf8"));
|
|
17
|
+
if (!raw.keys?.length) {
|
|
18
|
+
throw new Error(`No keys found in ${keyFile}`);
|
|
19
|
+
}
|
|
20
|
+
const byKid = new Map();
|
|
21
|
+
for (const entry of raw.keys) {
|
|
22
|
+
const key = Buffer.from(entry.k, "base64url");
|
|
23
|
+
if (key.length !== 32) {
|
|
24
|
+
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
25
|
+
}
|
|
26
|
+
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
27
|
+
}
|
|
28
|
+
const activeEntry = raw.keys.find((key) => key.status === "active") ?? (requireActive ? null : raw.keys[0]);
|
|
29
|
+
if (!activeEntry) {
|
|
30
|
+
throw new Error("No active key found in local key file");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
active: byKid.get(activeEntry.kid),
|
|
34
|
+
byKid
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
export function createLocalCryptoProvider({ keyFile }) {
|
|
8
39
|
if (!keyFile) {
|
|
9
40
|
throw new Error("Local crypto provider requires keyFile");
|
|
@@ -15,23 +46,7 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
15
46
|
if (cachedKeys) {
|
|
16
47
|
return cachedKeys;
|
|
17
48
|
}
|
|
18
|
-
|
|
19
|
-
if (!raw.keys?.length) {
|
|
20
|
-
throw new Error(`No keys found in ${keyFile}`);
|
|
21
|
-
}
|
|
22
|
-
const byKid = new Map();
|
|
23
|
-
for (const entry of raw.keys) {
|
|
24
|
-
const key = Buffer.from(entry.k, "base64url");
|
|
25
|
-
if (key.length !== 32) {
|
|
26
|
-
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
27
|
-
}
|
|
28
|
-
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
29
|
-
}
|
|
30
|
-
const activeEntry = raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
|
|
31
|
-
cachedKeys = {
|
|
32
|
-
active: byKid.get(activeEntry.kid),
|
|
33
|
-
byKid
|
|
34
|
-
};
|
|
49
|
+
cachedKeys = await loadKeyFile(keyFile);
|
|
35
50
|
return cachedKeys;
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -102,15 +117,22 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
|
|
|
102
117
|
await mkdir(dirname(keyFile), { recursive: true });
|
|
103
118
|
|
|
104
119
|
let existing = null;
|
|
120
|
+
let fileExists = true;
|
|
105
121
|
try {
|
|
106
122
|
existing = JSON.parse(await readFile(keyFile, "utf8"));
|
|
107
|
-
if (!force) {
|
|
108
|
-
return { created: false, keyFile };
|
|
109
|
-
}
|
|
110
123
|
} catch (error) {
|
|
111
124
|
if (error.code !== "ENOENT") {
|
|
112
125
|
throw error;
|
|
113
126
|
}
|
|
127
|
+
fileExists = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (fileExists && !force) {
|
|
131
|
+
// A present key file must be usable, not merely present: validate the
|
|
132
|
+
// active key (base64url, 32 bytes) and every retired key before reporting
|
|
133
|
+
// success, so a corrupted file is rejected here rather than at first use.
|
|
134
|
+
await loadKeyFile(keyFile, { requireActive: true });
|
|
135
|
+
return { created: false, keyFile };
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
// Rotating with --force must not orphan existing envelopes/token vault
|
|
@@ -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));
|