haechi 1.0.0 → 1.1.1
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 +14 -8
- package/README.md +14 -8
- package/SECURITY.md +1 -1
- package/docs/README.md +1 -1
- package/docs/current/configuration.ko.md +23 -4
- package/docs/current/configuration.md +23 -4
- package/docs/current/open-source-modular-architecture.ko.md +1 -1
- package/docs/current/open-source-modular-architecture.md +1 -1
- package/docs/current/release-1.1-implementation-scope.ko.md +128 -0
- package/docs/current/release-1.1-implementation-scope.md +128 -0
- package/docs/current/risk-register-release-gate.ko.md +9 -1
- package/docs/current/risk-register-release-gate.md +9 -1
- package/docs/current/threat-model.ko.md +11 -5
- package/docs/current/threat-model.md +11 -5
- package/haechi.config.example.json +1 -1
- package/package.json +4 -3
- package/packages/cli/bin/haechi.mjs +2 -2
- package/packages/cli/runtime.mjs +55 -9
- package/packages/plugin/index.mjs +27 -17
- package/packages/plugin/process-sandbox.mjs +629 -0
- package/packages/plugin/sandbox-common.mjs +243 -0
- package/packages/plugin/sandbox.mjs +24 -217
- package/packages/proxy/index.mjs +1 -1
- package/packages/ssrf/index.mjs +189 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Shared, security-critical primitives for the signed-authProvider sandboxes
|
|
2
|
+
// (Haechi 1.0 worker-isolated + 1.1 process-isolated).
|
|
3
|
+
//
|
|
4
|
+
// These helpers are the trust boundary that MUST behave identically for both
|
|
5
|
+
// runtimes — the null-prototype claims sanitizer, the bearer-only credential
|
|
6
|
+
// extraction, the signed-envelope reconstruction, and the full PR2 load gate.
|
|
7
|
+
// They live here so the worker and process sandboxes import ONE copy: a divergence
|
|
8
|
+
// between two private copies of the sanitizer would be a real prototype-pollution
|
|
9
|
+
// vulnerability. The transport-specific lifecycle (Worker vs child_process) stays
|
|
10
|
+
// in each sandbox module.
|
|
11
|
+
//
|
|
12
|
+
// Zero runtime dependency: node:crypto + node:fs + node:path only, plus the
|
|
13
|
+
// in-repo PR2 verifier (haechi/plugin/signing) and the manifest validator.
|
|
14
|
+
|
|
15
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { dirname, resolve as resolvePath, sep as pathSep } from "node:path";
|
|
18
|
+
import { verifySignedPlugin } from "./signing.mjs";
|
|
19
|
+
import { validatePluginManifest } from "./index.mjs";
|
|
20
|
+
|
|
21
|
+
// The only own-enumerable keys the host accepts back from a sandboxed plugin.
|
|
22
|
+
// Anything else (incl. __proto__/constructor/prototype) is dropped at the boundary.
|
|
23
|
+
export const CLAIM_ALLOWLIST = ["subject", "issuer", "type", "scopes", "labels"];
|
|
24
|
+
// Defensive bounds so a hostile claims object cannot blow up the host build.
|
|
25
|
+
export const MAX_SCOPES = 64;
|
|
26
|
+
export const MAX_LABELS = 32;
|
|
27
|
+
export const MAX_STRING_LEN = 1024;
|
|
28
|
+
// A self-contained single-file plugin is loaded from a data: URL; refuse to read
|
|
29
|
+
// an unreasonably large entry into memory (a few MiB is generous for any auth
|
|
30
|
+
// plugin). Shared so both runtimes apply the identical bound.
|
|
31
|
+
export const MAX_ENTRY_BYTES = 4 * 1024 * 1024; // 4 MiB
|
|
32
|
+
|
|
33
|
+
export function sha256Hex(bytes) {
|
|
34
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Same parsing the bearer provider uses — ONLY the Authorization header, never
|
|
38
|
+
// the request body. Returns the bearer token slice (the credential) or null.
|
|
39
|
+
export function bearerCredentialFromRequest(request) {
|
|
40
|
+
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
41
|
+
if (typeof header !== "string") {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
45
|
+
return match ? match[1].trim() : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reconstruct the PR2 signed envelope ({ payload, signerKeyId, alg, signature })
|
|
49
|
+
// from a manifest's stored fields. Authors produce this with signPluginManifest;
|
|
50
|
+
// the manifest persists it under haechiPlugin.signed and mirrors the flat fields
|
|
51
|
+
// so validatePluginManifest can check the shape.
|
|
52
|
+
export function envelopeFromManifest(plugin) {
|
|
53
|
+
if (plugin.signed && typeof plugin.signed === "object") {
|
|
54
|
+
return plugin.signed;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
payload: plugin.signedPayload,
|
|
58
|
+
signerKeyId: plugin.signerKeyId,
|
|
59
|
+
alg: plugin.alg ?? "ed25519",
|
|
60
|
+
signature: plugin.signature
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Host-side claims sanitizer. The reply is parsed, then ONLY the allowlisted
|
|
65
|
+
// own-enumerable keys are copied onto a null-prototype object — __proto__/
|
|
66
|
+
// constructor/prototype can never reach buildExternalIdentity, and array/string
|
|
67
|
+
// sizes are bounded. Returns a plain {subject,issuer,type,scopes,labels} or
|
|
68
|
+
// throws (→ deny) on a structurally invalid claim.
|
|
69
|
+
export function sanitizeClaims(rawClaims) {
|
|
70
|
+
if (!rawClaims || typeof rawClaims !== "object" || Array.isArray(rawClaims)) {
|
|
71
|
+
throw new Error("claims must be an object");
|
|
72
|
+
}
|
|
73
|
+
const out = Object.create(null);
|
|
74
|
+
for (const key of CLAIM_ALLOWLIST) {
|
|
75
|
+
// Own-enumerable only; never walk the prototype.
|
|
76
|
+
if (!Object.prototype.hasOwnProperty.call(rawClaims, key)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
out[key] = rawClaims[key];
|
|
80
|
+
}
|
|
81
|
+
// type-validate / bound each value at the boundary.
|
|
82
|
+
if (typeof out.subject !== "string" || out.subject.length === 0 || out.subject.length > MAX_STRING_LEN) {
|
|
83
|
+
throw new Error("claims.subject must be a bounded non-empty string");
|
|
84
|
+
}
|
|
85
|
+
if (typeof out.issuer !== "string" || out.issuer.length === 0 || out.issuer.length > MAX_STRING_LEN) {
|
|
86
|
+
throw new Error("claims.issuer must be a bounded non-empty string");
|
|
87
|
+
}
|
|
88
|
+
if (out.type !== undefined && typeof out.type !== "string") {
|
|
89
|
+
throw new Error("claims.type must be a string");
|
|
90
|
+
}
|
|
91
|
+
if (out.scopes !== undefined) {
|
|
92
|
+
if (!Array.isArray(out.scopes) || out.scopes.length > MAX_SCOPES
|
|
93
|
+
|| !out.scopes.every((s) => typeof s === "string" && s.length > 0 && s.length <= MAX_STRING_LEN)) {
|
|
94
|
+
throw new Error("claims.scopes must be a bounded array of non-empty strings");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (out.labels !== undefined) {
|
|
98
|
+
if (!out.labels || typeof out.labels !== "object" || Array.isArray(out.labels)) {
|
|
99
|
+
throw new Error("claims.labels must be an object");
|
|
100
|
+
}
|
|
101
|
+
const labelKeys = Object.keys(out.labels);
|
|
102
|
+
if (labelKeys.length > MAX_LABELS) {
|
|
103
|
+
throw new Error("claims.labels exceeds the size bound");
|
|
104
|
+
}
|
|
105
|
+
const bounded = Object.create(null);
|
|
106
|
+
for (const k of labelKeys) {
|
|
107
|
+
const v = out.labels[k];
|
|
108
|
+
if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRING_LEN) {
|
|
109
|
+
throw new Error(`claims.labels.${k} must be a bounded non-empty string`);
|
|
110
|
+
}
|
|
111
|
+
bounded[k] = v;
|
|
112
|
+
}
|
|
113
|
+
out.labels = bounded;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A fire-and-forget audit wrapper. Lifecycle audit must NEVER make the auth path
|
|
119
|
+
// throw, so a sink that throws or rejects is swallowed.
|
|
120
|
+
export function makeFireAndForgetAudit(auditSink) {
|
|
121
|
+
return (event) => {
|
|
122
|
+
try {
|
|
123
|
+
const out = auditSink.record(event);
|
|
124
|
+
if (out && typeof out.then === "function") {
|
|
125
|
+
out.catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// swallow — auditing is best-effort and never blocks fail-closed behavior
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read+validate the manifest, resolve the entry path, read the entry bytes into
|
|
134
|
+
// memory, and run the FULL PR2 gate. Returns { verified, entrySource,
|
|
135
|
+
// entrySha256, pluginId, signerKeyId }. Throws (after emitting plugin.load.refused
|
|
136
|
+
// via `audit`) on any refusal. Both sandboxes call this; `expectedRuntime` is the
|
|
137
|
+
// manifest runtime string the caller requires ("worker-isolated" | "process-isolated").
|
|
138
|
+
// Re-run on every (re)spawn — the gate is not a one-time check.
|
|
139
|
+
export function loadAndVerifyPlugin({
|
|
140
|
+
manifestPath,
|
|
141
|
+
expectedRuntime,
|
|
142
|
+
trustAnchors,
|
|
143
|
+
allowCapabilities = [],
|
|
144
|
+
pin = null,
|
|
145
|
+
revoked = {},
|
|
146
|
+
versionFloor = {},
|
|
147
|
+
coreVersion = null,
|
|
148
|
+
now,
|
|
149
|
+
audit
|
|
150
|
+
}) {
|
|
151
|
+
// A tagged-throw helper so a refusal emits the audit at one site.
|
|
152
|
+
function refuse(reason, message, pluginId) {
|
|
153
|
+
const err = new Error(message);
|
|
154
|
+
err.reason = reason;
|
|
155
|
+
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId });
|
|
156
|
+
return { __haechiRefusal: true, cause: err };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let plugin;
|
|
160
|
+
let entrySource;
|
|
161
|
+
let entrySha256;
|
|
162
|
+
let pluginIdForAudit;
|
|
163
|
+
let signerKeyIdForAudit;
|
|
164
|
+
try {
|
|
165
|
+
const manifestRaw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
166
|
+
plugin = manifestRaw?.haechiPlugin;
|
|
167
|
+
pluginIdForAudit = plugin?.id;
|
|
168
|
+
const validation = validatePluginManifest(manifestRaw);
|
|
169
|
+
if (!validation.valid) {
|
|
170
|
+
throw refuse("manifest-invalid", `manifest invalid: ${validation.errors.join("; ")}`);
|
|
171
|
+
}
|
|
172
|
+
if (plugin.runtime !== expectedRuntime) {
|
|
173
|
+
throw refuse("manifest-invalid", `sandbox requires runtime ${expectedRuntime}`);
|
|
174
|
+
}
|
|
175
|
+
if (plugin.kind !== "authProvider") {
|
|
176
|
+
throw refuse("manifest-invalid", "sandbox requires kind authProvider");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Resolve the entry against the manifest dir. Reject a symlinked entry
|
|
180
|
+
// (anti-TOCTOU / swap): we hash and spawn from the in-memory bytes only.
|
|
181
|
+
const manifestDir = resolvePath(dirname(resolvePath(manifestPath)));
|
|
182
|
+
const entryPath = resolvePath(manifestDir, plugin.entrypoint);
|
|
183
|
+
|
|
184
|
+
// Entrypoint confinement: the resolved entry path MUST be inside the manifest
|
|
185
|
+
// directory. An absolute path or a `../`-escaping value resolves outside
|
|
186
|
+
// manifestDir and is an arbitrary-file-read primitive. Checked BEFORE any I/O.
|
|
187
|
+
if (!entryPath.startsWith(manifestDir + pathSep) && entryPath !== manifestDir) {
|
|
188
|
+
throw refuse("manifest-invalid", `entry path escapes the manifest directory: ${plugin.entrypoint}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const st = lstatSync(entryPath);
|
|
192
|
+
if (st.isSymbolicLink()) {
|
|
193
|
+
throw refuse("tampered-entry", "entry path is a symlink (refused)");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entrySize = statSync(entryPath).size;
|
|
197
|
+
if (entrySize > MAX_ENTRY_BYTES) {
|
|
198
|
+
throw refuse("manifest-invalid", `entry file exceeds maximum size (${entrySize} > ${MAX_ENTRY_BYTES} bytes)`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const entryBytes = readFileSync(entryPath); // INTO MEMORY — read exactly once.
|
|
202
|
+
entrySource = entryBytes.toString("utf8");
|
|
203
|
+
entrySha256 = sha256Hex(entryBytes);
|
|
204
|
+
|
|
205
|
+
signerKeyIdForAudit = envelopeFromManifest(plugin)?.signerKeyId;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error?.__haechiRefusal) {
|
|
208
|
+
throw error.cause;
|
|
209
|
+
}
|
|
210
|
+
const refusal = refuse("manifest-invalid", `manifest load failed: ${error.message}`, pluginIdForAudit);
|
|
211
|
+
throw refusal.cause;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The PR2 gate (signature + anchor + revocation + tamper + window + floor +
|
|
215
|
+
// pin + capability allowlist + coreVersionRange). Any failure throws a
|
|
216
|
+
// PluginLoadError whose .reason is the audit reason.
|
|
217
|
+
let verified;
|
|
218
|
+
try {
|
|
219
|
+
verified = verifySignedPlugin({
|
|
220
|
+
signed: envelopeFromManifest(plugin),
|
|
221
|
+
entryBytes: Buffer.from(entrySource, "utf8"),
|
|
222
|
+
trustAnchors,
|
|
223
|
+
revoked,
|
|
224
|
+
pin,
|
|
225
|
+
versionFloor,
|
|
226
|
+
allowCapabilities,
|
|
227
|
+
coreVersion,
|
|
228
|
+
now
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const reason = typeof error?.reason === "string" ? error.reason : "manifest-invalid";
|
|
232
|
+
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId: pluginIdForAudit, signerKeyId: signerKeyIdForAudit });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
verified,
|
|
238
|
+
entrySource,
|
|
239
|
+
entrySha256,
|
|
240
|
+
pluginId: verified.pluginId,
|
|
241
|
+
signerKeyId: envelopeFromManifest(plugin).signerKeyId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -17,20 +17,14 @@
|
|
|
17
17
|
// in-repo haechi/plugin (PR2 verify) and haechi/auth (identity + conformance).
|
|
18
18
|
|
|
19
19
|
import { Worker } from "node:worker_threads";
|
|
20
|
-
import {
|
|
21
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
22
|
-
import { dirname, resolve as resolvePath, sep as pathSep } from "node:path";
|
|
23
|
-
import { verifySignedPlugin } from "./signing.mjs";
|
|
24
|
-
import { validatePluginManifest } from "./index.mjs";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
25
21
|
import { assertAuthProviderConformance, buildExternalIdentity } from "../auth/index.mjs";
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const MAX_LABELS = 32;
|
|
33
|
-
const MAX_STRING_LEN = 1024;
|
|
22
|
+
import {
|
|
23
|
+
bearerCredentialFromRequest,
|
|
24
|
+
loadAndVerifyPlugin,
|
|
25
|
+
makeFireAndForgetAudit,
|
|
26
|
+
sanitizeClaims
|
|
27
|
+
} from "./sandbox-common.mjs";
|
|
34
28
|
|
|
35
29
|
// The wire harness the host wraps around the worker so a generic codeString plugin
|
|
36
30
|
// only has to .on("message")/.postMessage JSON strings. Each plugin entry exports
|
|
@@ -73,93 +67,6 @@ function workerHarness(entrySource) {
|
|
|
73
67
|
].join("\n");
|
|
74
68
|
}
|
|
75
69
|
|
|
76
|
-
function sha256Hex(bytes) {
|
|
77
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Same parsing the bearer provider uses — ONLY the Authorization header, never
|
|
81
|
-
// the request body. Returns the bearer token slice (the credential) or null.
|
|
82
|
-
function bearerCredentialFromRequest(request) {
|
|
83
|
-
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
84
|
-
if (typeof header !== "string") {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
88
|
-
return match ? match[1].trim() : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Reconstruct the PR2 signed envelope ({ payload, signerKeyId, alg, signature })
|
|
92
|
-
// from a worker-isolated manifest's stored fields. Authors produce this with
|
|
93
|
-
// signPluginManifest; the manifest persists it under haechiPlugin.signed and
|
|
94
|
-
// mirrors the flat fields so validatePluginManifest can check the shape.
|
|
95
|
-
function envelopeFromManifest(plugin) {
|
|
96
|
-
if (plugin.signed && typeof plugin.signed === "object") {
|
|
97
|
-
return plugin.signed;
|
|
98
|
-
}
|
|
99
|
-
// Fallback: assemble from the flat manifest fields (signature/signerKeyId +
|
|
100
|
-
// the signed payload mirror under haechiPlugin.signedPayload).
|
|
101
|
-
return {
|
|
102
|
-
payload: plugin.signedPayload,
|
|
103
|
-
signerKeyId: plugin.signerKeyId,
|
|
104
|
-
alg: plugin.alg ?? "ed25519",
|
|
105
|
-
signature: plugin.signature
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Host-side claims sanitizer. The reply is parsed, then ONLY the allowlisted
|
|
110
|
-
// own-enumerable keys are copied onto a null-prototype object — __proto__/
|
|
111
|
-
// constructor/prototype can never reach buildExternalIdentity, and array/string
|
|
112
|
-
// sizes are bounded. Returns a plain {subject,issuer,type,scopes,labels} or
|
|
113
|
-
// throws (→ deny) on a structurally invalid claim.
|
|
114
|
-
function sanitizeClaims(rawClaims) {
|
|
115
|
-
if (!rawClaims || typeof rawClaims !== "object" || Array.isArray(rawClaims)) {
|
|
116
|
-
throw new Error("claims must be an object");
|
|
117
|
-
}
|
|
118
|
-
const out = Object.create(null);
|
|
119
|
-
for (const key of CLAIM_ALLOWLIST) {
|
|
120
|
-
// Own-enumerable only; never walk the prototype.
|
|
121
|
-
if (!Object.prototype.hasOwnProperty.call(rawClaims, key)) {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
out[key] = rawClaims[key];
|
|
125
|
-
}
|
|
126
|
-
// type-validate / bound each value at the boundary.
|
|
127
|
-
if (typeof out.subject !== "string" || out.subject.length === 0 || out.subject.length > MAX_STRING_LEN) {
|
|
128
|
-
throw new Error("claims.subject must be a bounded non-empty string");
|
|
129
|
-
}
|
|
130
|
-
if (typeof out.issuer !== "string" || out.issuer.length === 0 || out.issuer.length > MAX_STRING_LEN) {
|
|
131
|
-
throw new Error("claims.issuer must be a bounded non-empty string");
|
|
132
|
-
}
|
|
133
|
-
if (out.type !== undefined && typeof out.type !== "string") {
|
|
134
|
-
throw new Error("claims.type must be a string");
|
|
135
|
-
}
|
|
136
|
-
if (out.scopes !== undefined) {
|
|
137
|
-
if (!Array.isArray(out.scopes) || out.scopes.length > MAX_SCOPES
|
|
138
|
-
|| !out.scopes.every((s) => typeof s === "string" && s.length > 0 && s.length <= MAX_STRING_LEN)) {
|
|
139
|
-
throw new Error("claims.scopes must be a bounded array of non-empty strings");
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (out.labels !== undefined) {
|
|
143
|
-
if (!out.labels || typeof out.labels !== "object" || Array.isArray(out.labels)) {
|
|
144
|
-
throw new Error("claims.labels must be an object");
|
|
145
|
-
}
|
|
146
|
-
const labelKeys = Object.keys(out.labels);
|
|
147
|
-
if (labelKeys.length > MAX_LABELS) {
|
|
148
|
-
throw new Error("claims.labels exceeds the size bound");
|
|
149
|
-
}
|
|
150
|
-
const bounded = Object.create(null);
|
|
151
|
-
for (const k of labelKeys) {
|
|
152
|
-
const v = out.labels[k];
|
|
153
|
-
if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRING_LEN) {
|
|
154
|
-
throw new Error(`claims.labels.${k} must be a bounded non-empty string`);
|
|
155
|
-
}
|
|
156
|
-
bounded[k] = v;
|
|
157
|
-
}
|
|
158
|
-
out.labels = bounded;
|
|
159
|
-
}
|
|
160
|
-
return out;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
70
|
function createSandboxedAuthProviderHandle({
|
|
164
71
|
manifestPath,
|
|
165
72
|
trustAnchors,
|
|
@@ -198,125 +105,25 @@ function createSandboxedAuthProviderHandle({
|
|
|
198
105
|
const nowFn = typeof now === "function" ? now : () => now;
|
|
199
106
|
|
|
200
107
|
// Fire-and-forget audit; lifecycle audit must never make the auth path throw.
|
|
201
|
-
const audit = (
|
|
202
|
-
try {
|
|
203
|
-
const out = auditSink.record(event);
|
|
204
|
-
if (out && typeof out.then === "function") {
|
|
205
|
-
out.catch(() => {});
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
// swallow — auditing is best-effort and never blocks fail-closed behavior
|
|
209
|
-
}
|
|
210
|
-
};
|
|
108
|
+
const audit = makeFireAndForgetAudit(auditSink);
|
|
211
109
|
|
|
212
|
-
// Read+validate the manifest
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
110
|
+
// Read+validate the manifest + run the FULL PR2 gate. The trust boundary is
|
|
111
|
+
// shared with the process-isolated runtime (packages/plugin/sandbox-common.mjs)
|
|
112
|
+
// so the two sandboxes cannot diverge. Re-run on every (re)spawn — the gate is
|
|
113
|
+
// not a one-time check.
|
|
216
114
|
function loadAndVerify() {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!validation.valid) {
|
|
230
|
-
throw refuse("manifest-invalid", `manifest invalid: ${validation.errors.join("; ")}`);
|
|
231
|
-
}
|
|
232
|
-
if (plugin.runtime !== "worker-isolated") {
|
|
233
|
-
throw refuse("manifest-invalid", "sandbox requires runtime worker-isolated");
|
|
234
|
-
}
|
|
235
|
-
if (plugin.kind !== "authProvider") {
|
|
236
|
-
throw refuse("manifest-invalid", "sandbox requires kind authProvider");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Resolve the entry against the manifest dir. Reject a symlinked entry
|
|
240
|
-
// (anti-TOCTOU / swap): we hash and spawn from the in-memory bytes only.
|
|
241
|
-
const manifestDir = resolvePath(dirname(resolvePath(manifestPath)));
|
|
242
|
-
entryPath = resolvePath(manifestDir, plugin.entrypoint);
|
|
243
|
-
|
|
244
|
-
// FIX C — entrypoint confinement: the resolved entry path MUST be inside
|
|
245
|
-
// the manifest directory. An absolute path or a `../`-escaping value
|
|
246
|
-
// resolves outside manifestDir and is an arbitrary-file-read primitive
|
|
247
|
-
// (code execution is still blocked by the entrySha256 hash check, but
|
|
248
|
-
// reading an arbitrary host file into memory is unintended).
|
|
249
|
-
// We check BEFORE lstatSync / readFileSync so no I/O occurs on the path.
|
|
250
|
-
if (!entryPath.startsWith(manifestDir + pathSep) && entryPath !== manifestDir) {
|
|
251
|
-
throw refuse("manifest-invalid", `entry path escapes the manifest directory: ${plugin.entrypoint}`);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const st = lstatSync(entryPath);
|
|
255
|
-
if (st.isSymbolicLink()) {
|
|
256
|
-
throw refuse("tampered-entry", "entry path is a symlink (refused)");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// FIX C — max-size bound: refuse to read an unreasonably large entry into
|
|
260
|
-
// memory. A few MiB is generous for any auth plugin; beyond that it is
|
|
261
|
-
// almost certainly a mistake or an attempt to exhaust host memory.
|
|
262
|
-
const MAX_ENTRY_BYTES = 4 * 1024 * 1024; // 4 MiB
|
|
263
|
-
const entrySize = statSync(entryPath).size;
|
|
264
|
-
if (entrySize > MAX_ENTRY_BYTES) {
|
|
265
|
-
throw refuse("manifest-invalid", `entry file exceeds maximum size (${entrySize} > ${MAX_ENTRY_BYTES} bytes)`);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const entryBytes = readFileSync(entryPath); // INTO MEMORY — read exactly once.
|
|
269
|
-
entrySource = entryBytes.toString("utf8");
|
|
270
|
-
entrySha256 = sha256Hex(entryBytes);
|
|
271
|
-
|
|
272
|
-
const envelope = envelopeFromManifest(plugin);
|
|
273
|
-
signerKeyIdForAudit = envelope?.signerKeyId;
|
|
274
|
-
} catch (error) {
|
|
275
|
-
if (error?.__haechiRefusal) {
|
|
276
|
-
throw error.cause;
|
|
277
|
-
}
|
|
278
|
-
// A read/parse error before validation runs as a manifest refusal.
|
|
279
|
-
const refusal = refuse("manifest-invalid", `manifest load failed: ${error.message}`, pluginIdForAudit);
|
|
280
|
-
throw refusal.cause;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// The PR2 gate (signature + anchor + revocation + tamper + window + floor +
|
|
284
|
-
// pin + capability allowlist + coreVersionRange). Any failure throws a
|
|
285
|
-
// PluginLoadError whose .reason is the audit reason.
|
|
286
|
-
let verified;
|
|
287
|
-
try {
|
|
288
|
-
verified = verifySignedPlugin({
|
|
289
|
-
signed: envelopeFromManifest(plugin),
|
|
290
|
-
entryBytes: Buffer.from(entrySource, "utf8"),
|
|
291
|
-
trustAnchors,
|
|
292
|
-
revoked,
|
|
293
|
-
pin,
|
|
294
|
-
versionFloor,
|
|
295
|
-
allowCapabilities,
|
|
296
|
-
coreVersion,
|
|
297
|
-
now: nowFn()
|
|
298
|
-
});
|
|
299
|
-
} catch (error) {
|
|
300
|
-
const reason = typeof error?.reason === "string" ? error.reason : "manifest-invalid";
|
|
301
|
-
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId: pluginIdForAudit, signerKeyId: signerKeyIdForAudit });
|
|
302
|
-
throw error;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return {
|
|
306
|
-
verified,
|
|
307
|
-
entrySource,
|
|
308
|
-
entrySha256,
|
|
309
|
-
pluginId: verified.pluginId,
|
|
310
|
-
signerKeyId: envelopeFromManifest(plugin).signerKeyId
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// A tagged-throw helper so loadAndVerify can emit the refused audit at one site.
|
|
315
|
-
function refuse(reason, message, pluginId) {
|
|
316
|
-
const err = new Error(message);
|
|
317
|
-
err.reason = reason;
|
|
318
|
-
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId });
|
|
319
|
-
return { __haechiRefusal: true, cause: err };
|
|
115
|
+
return loadAndVerifyPlugin({
|
|
116
|
+
manifestPath,
|
|
117
|
+
expectedRuntime: "worker-isolated",
|
|
118
|
+
trustAnchors,
|
|
119
|
+
allowCapabilities,
|
|
120
|
+
pin,
|
|
121
|
+
revoked,
|
|
122
|
+
versionFloor,
|
|
123
|
+
coreVersion,
|
|
124
|
+
now: nowFn(),
|
|
125
|
+
audit
|
|
126
|
+
});
|
|
320
127
|
}
|
|
321
128
|
|
|
322
129
|
// ---- worker lifecycle ----------------------------------------------------
|
package/packages/proxy/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { inspectResponseStream } from "../stream-filter/index.mjs";
|
|
4
4
|
|
|
5
|
-
export const DEFAULT_PROXY_PORT =
|
|
5
|
+
export const DEFAULT_PROXY_PORT = 11016;
|
|
6
6
|
|
|
7
7
|
export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
|
|
8
8
|
assertSafeProxyBind({ host, allowRemoteBind });
|