@questpie/sandbox 3.1.0
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.md +58 -0
- package/dist/adapter-http-7ypWHBck.d.mts +43 -0
- package/dist/adapter-http-w7SjAfcC.mjs +298 -0
- package/dist/adapter.d.mts +2 -0
- package/dist/adapter.mjs +3 -0
- package/dist/index.d.mts +280 -0
- package/dist/index.mjs +69 -0
- package/package.json +71 -0
- package/src/egress-firewall.ts +330 -0
- package/src/guest-bindings.ts +146 -0
- package/src/guest-entry.ts +446 -0
- package/src/net-validation.ts +359 -0
- package/src/sandbox-server.ts +682 -0
- package/src/server-internals.ts +127 -0
- package/src/types.ts +132 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { a as validateEgressHosts, i as parseHostEntry, n as httpSandboxAdapter, o as validateHostEgress, r as classifyIpLiteral, t as HttpSandboxAdapter } from "./adapter-http-w7SjAfcC.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/types.ts
|
|
4
|
+
/** Line prefix marking a framed protocol message on guest stdout. */
|
|
5
|
+
const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
6
|
+
/**
|
|
7
|
+
* Header carrying the per-run scoped token on the supervisor → broker request.
|
|
8
|
+
* Mirrors `questpie`'s `BINDINGS_TOKEN_HEADER`. Supervisor-only; never set by or
|
|
9
|
+
* exposed to guest code.
|
|
10
|
+
*/
|
|
11
|
+
const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/guest-bindings.ts
|
|
15
|
+
/**
|
|
16
|
+
* Build the guest bindings proxy over an injected {@link HostCall}.
|
|
17
|
+
*
|
|
18
|
+
* `collections` is a Proxy so any collection NAME resolves to a `{ find,
|
|
19
|
+
* findOne }` shim — capability enforcement for the specific collection happens
|
|
20
|
+
* HOST-SIDE in the broker (the guest can't pre-know which names are granted, and
|
|
21
|
+
* the proxy must not leak that). An out-of-scope collection call simply rejects
|
|
22
|
+
* when the host denies it.
|
|
23
|
+
*/
|
|
24
|
+
function buildGuestBindings(hostCall) {
|
|
25
|
+
return {
|
|
26
|
+
files: {
|
|
27
|
+
read: (args) => hostCall("files.read", args),
|
|
28
|
+
write: (args) => hostCall("files.write", args),
|
|
29
|
+
list: (args) => hostCall("files.list", args ?? {})
|
|
30
|
+
},
|
|
31
|
+
collections: new Proxy({}, { get(_t, name) {
|
|
32
|
+
if (typeof name !== "string") return void 0;
|
|
33
|
+
return {
|
|
34
|
+
find: (args) => hostCall(`collections.${name}.find`, args ?? {}),
|
|
35
|
+
findOne: (args) => hostCall(`collections.${name}.findOne`, args ?? {}),
|
|
36
|
+
create: (args) => hostCall(`collections.${name}.create`, args ?? {}),
|
|
37
|
+
update: (args) => hostCall(`collections.${name}.update`, args ?? {}),
|
|
38
|
+
delete: (args) => hostCall(`collections.${name}.delete`, args ?? {})
|
|
39
|
+
};
|
|
40
|
+
} }),
|
|
41
|
+
store: new Proxy({}, { get(_t, name) {
|
|
42
|
+
if (typeof name !== "string") return void 0;
|
|
43
|
+
const withStoreWhere = (args) => {
|
|
44
|
+
const where = args.where && typeof args.where === "object" ? {
|
|
45
|
+
...args.where,
|
|
46
|
+
store: name
|
|
47
|
+
} : { store: name };
|
|
48
|
+
return {
|
|
49
|
+
...args,
|
|
50
|
+
where
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const ds = (op, args) => hostCall(`collections.document_store.${op}`, args);
|
|
54
|
+
return {
|
|
55
|
+
find: (args = {}) => ds("find", withStoreWhere(args)),
|
|
56
|
+
findOne: (args = {}) => ds("findOne", withStoreWhere(args)),
|
|
57
|
+
create: (args) => ds("create", {
|
|
58
|
+
...args ?? {},
|
|
59
|
+
store: name
|
|
60
|
+
}),
|
|
61
|
+
update: (args) => ds("update", withStoreWhere(args ?? {})),
|
|
62
|
+
delete: (args = {}) => ds("delete", withStoreWhere(args))
|
|
63
|
+
};
|
|
64
|
+
} })
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
export { BINDINGS_TOKEN_HEADER, FRAME_MARKER, HttpSandboxAdapter, buildGuestBindings, classifyIpLiteral, httpSandboxAdapter, parseHostEntry, validateEgressHosts, validateHostEgress };
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@questpie/sandbox",
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/questpie/questpie.git",
|
|
8
|
+
"directory": "packages/sandbox"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src/sandbox-server.ts",
|
|
13
|
+
"src/guest-entry.ts",
|
|
14
|
+
"src/net-validation.ts",
|
|
15
|
+
"src/server-internals.ts",
|
|
16
|
+
"src/egress-firewall.ts",
|
|
17
|
+
"src/types.ts",
|
|
18
|
+
"src/guest-bindings.ts"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"main": "./dist/index.mjs",
|
|
23
|
+
"module": "./dist/index.mjs",
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./src/exports/index.ts",
|
|
28
|
+
"default": "./src/exports/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"./adapter": {
|
|
31
|
+
"types": "./src/exports/adapter.ts",
|
|
32
|
+
"default": "./src/exports/adapter.ts"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": {
|
|
35
|
+
"types": "./package.json",
|
|
36
|
+
"default": "./package.json"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"types": "./dist/index.d.mts",
|
|
44
|
+
"default": "./dist/index.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./adapter": {
|
|
47
|
+
"types": "./dist/adapter.d.mts",
|
|
48
|
+
"default": "./dist/adapter.mjs"
|
|
49
|
+
},
|
|
50
|
+
"./package.json": {
|
|
51
|
+
"types": "./package.json",
|
|
52
|
+
"default": "./package.json"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"check-types": "tsc --noEmit",
|
|
59
|
+
"test": "bun test"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"questpie": "workspace:*"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"bun-types": "latest",
|
|
66
|
+
"tsdown": "^0.18.3"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"questpie": "^3.0.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth egress firewall (LINUX-ONLY, cloud workers) — milestone S6 of
|
|
3
|
+
* spec `sandbox-brokered-http-egress`.
|
|
4
|
+
*
|
|
5
|
+
* ── What this is ────────────────────────────────────────────────────────────
|
|
6
|
+
* A *belt-and-suspenders* kernel-level egress drop for the guest `deno run`
|
|
7
|
+
* subprocess. The BASELINE SSRF defense is the brokered `http.fetch` path: the
|
|
8
|
+
* guest runs with `--allow-net=[]` and cannot open sockets at all, so it has no
|
|
9
|
+
* network reach to lose. This layer adds a SECOND, independent boundary at the
|
|
10
|
+
* KERNEL: even if a future change granted the guest raw `--allow-net`, or a
|
|
11
|
+
* low-level escape (e.g. a Deno/V8 bug, a smuggled `--allow-import` host that
|
|
12
|
+
* rebinds) opened a socket, the kernel DROPS every packet to a
|
|
13
|
+
* private/link-local/loopback/metadata address. Only the resolved per-run
|
|
14
|
+
* allowlist (plus the host broker) is permitted. This is what Deno Subhosting
|
|
15
|
+
* does for its isolates.
|
|
16
|
+
*
|
|
17
|
+
* ── Platform honesty ────────────────────────────────────────────────────────
|
|
18
|
+
* Network namespaces + nftables are a LINUX KERNEL feature. There is NO
|
|
19
|
+
* equivalent on macOS or Windows, and the kernel-drop behavior CANNOT be
|
|
20
|
+
* functionally verified off Linux. Therefore this module is designed to be
|
|
21
|
+
* GRACEFULLY ABSENT: {@link planEgressFirewall} returns a structured plan that
|
|
22
|
+
* the supervisor consults. On non-Linux — or on Linux when the required tools
|
|
23
|
+
* (`unshare`, `nft`, `ip`) or the `CAP_NET_ADMIN`/`CAP_SYS_ADMIN` capabilities
|
|
24
|
+
* are absent — the plan is `{ applied:false, reason }` and the supervisor spawns
|
|
25
|
+
* the guest EXACTLY as before. The local Mac/Windows workers are unaffected; the
|
|
26
|
+
* baseline stays safe via the brokered fetch.
|
|
27
|
+
*
|
|
28
|
+
* ── This file is PURE + testable ────────────────────────────────────────────
|
|
29
|
+
* Like `server-internals.ts`, this module holds NO Deno globals and NO top-level
|
|
30
|
+
* side effects, so it lands inside the package `tsconfig` `include` and is
|
|
31
|
+
* unit-tested under `bun:test`. The tests assert the LOGIC — the exact nftables
|
|
32
|
+
* ruleset text and the exact wrapped argv — which is what carries the security
|
|
33
|
+
* guarantee. They do NOT (and on macOS cannot) assert the kernel actually drops
|
|
34
|
+
* the packet; that is verified manually on a Linux worker (see the spec/task
|
|
35
|
+
* `## Verify` and the module-level Linux repro note below).
|
|
36
|
+
*
|
|
37
|
+
* The Deno-only wiring (platform/tool/cap probing + the real spawn wrap) lives
|
|
38
|
+
* in `sandbox-server.ts`, which is `@ts-nocheck` and excluded from the typecheck.
|
|
39
|
+
*
|
|
40
|
+
* ── LINUX MANUAL VERIFICATION (documented-only; needs a Linux worker) ────────
|
|
41
|
+
* 1. Run the sandbox-server on a Linux host (root or with CAP_NET_ADMIN +
|
|
42
|
+
* CAP_SYS_ADMIN; `unshare`, `nft`, `ip` on PATH).
|
|
43
|
+
* 2. POST /run a guest that is (for the test) granted `--allow-net` and tries
|
|
44
|
+
* `await fetch("http://169.254.169.254/latest/meta-data/")`.
|
|
45
|
+
* 3. Even though the broker is bypassed, the connect() is DROPPED at the
|
|
46
|
+
* kernel by the nft `output` chain → the guest's fetch fails with a network
|
|
47
|
+
* error; the metadata service is never reached.
|
|
48
|
+
* 4. A guest fetch to an allowlisted PUBLIC IP succeeds (it is in the per-run
|
|
49
|
+
* `accept` set). A fetch to any other public IP is also dropped (default
|
|
50
|
+
* policy is DROP; only the allowlist + broker are accepted).
|
|
51
|
+
* On macOS/Windows: {@link planEgressFirewall} returns `applied:false` and the
|
|
52
|
+
* guest spawns unchanged — the kernel drop is NOT exercised here.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Private / link-local / loopback / metadata ranges the firewall DROPS.
|
|
57
|
+
*
|
|
58
|
+
* These MUST stay in lock-step with the classifier in `net-validation.ts`
|
|
59
|
+
* (`isPrivateIpv4` / `isPrivateIpv6`) — the request-time allowlist check and the
|
|
60
|
+
* kernel drop must cover the SAME address space, or the two layers disagree.
|
|
61
|
+
* CIDR form (what `nft` consumes) of the exact same set.
|
|
62
|
+
*/
|
|
63
|
+
export const BLOCKED_IPV4_CIDRS: readonly string[] = [
|
|
64
|
+
"0.0.0.0/8", // "this host" / unspecified
|
|
65
|
+
"10.0.0.0/8", // RFC-1918 private
|
|
66
|
+
"100.64.0.0/10", // RFC-6598 CGNAT
|
|
67
|
+
"127.0.0.0/8", // loopback
|
|
68
|
+
"169.254.0.0/16", // link-local (incl. 169.254.169.254 cloud metadata)
|
|
69
|
+
"172.16.0.0/12", // RFC-1918 private
|
|
70
|
+
"192.168.0.0/16", // RFC-1918 private
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* IPv6 counterparts. `::ffff:0:0/96` (IPv4-mapped) is dropped wholesale so a
|
|
75
|
+
* mapped private literal cannot sneak through; allowlisted public targets are
|
|
76
|
+
* accepted by their NATIVE v4/v6 address, not a mapped form.
|
|
77
|
+
*/
|
|
78
|
+
export const BLOCKED_IPV6_CIDRS: readonly string[] = [
|
|
79
|
+
"::1/128", // loopback
|
|
80
|
+
"::/128", // unspecified
|
|
81
|
+
"::ffff:0:0/96", // IPv4-mapped (covers ::ffff:127.0.0.1, ::ffff:169.254.169.254, …)
|
|
82
|
+
"fc00::/7", // unique-local (incl. fd00:ec2::254 cloud metadata)
|
|
83
|
+
"fe80::/10", // link-local
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Name of the nft table the firewall installs (per-run, torn down with the netns). */
|
|
87
|
+
export const NFT_TABLE = "questpie_sandbox_egress";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A single resolved allowlist endpoint the guest is permitted to reach. The
|
|
91
|
+
* supervisor resolves each per-run allowlist host to its validated public IP(s)
|
|
92
|
+
* (reusing `net-validation.ts`) and pins them here, so the kernel `accept` rules
|
|
93
|
+
* reference IPs — never hostnames (nft cannot match DNS, and pinning the IP is
|
|
94
|
+
* itself part of the anti-rebind guarantee).
|
|
95
|
+
*/
|
|
96
|
+
export interface AllowedEndpoint {
|
|
97
|
+
/** Validated public IP literal (v4 or v6) the guest may egress to. */
|
|
98
|
+
ip: string;
|
|
99
|
+
/** Optional destination port to scope the accept to (e.g. 443). Omit = any port. */
|
|
100
|
+
port?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EgressFirewallPlanInput {
|
|
104
|
+
/** `globalThis.Deno.build.os` (or `process.platform`-mapped) — the only OS gate. */
|
|
105
|
+
os: string;
|
|
106
|
+
/**
|
|
107
|
+
* Per-run resolved allowlist + broker IP(s), already validated as PUBLIC by
|
|
108
|
+
* `net-validation.ts`. These become the kernel `accept` rules. Empty = the
|
|
109
|
+
* guest may reach nothing (default-deny), which is the correct posture for the
|
|
110
|
+
* brokered path (`--allow-net=[]`): nothing to allow, everything dropped.
|
|
111
|
+
*/
|
|
112
|
+
allow: readonly AllowedEndpoint[];
|
|
113
|
+
/**
|
|
114
|
+
* Which required tools were found on PATH (probed by the supervisor with
|
|
115
|
+
* `Deno.Command(...).spawn` / `which`). All must be present to apply the
|
|
116
|
+
* firewall; a missing tool → graceful no-op with a logged notice.
|
|
117
|
+
*/
|
|
118
|
+
tools: { unshare: boolean; nft: boolean; ip: boolean };
|
|
119
|
+
/**
|
|
120
|
+
* Whether the supervisor holds the namespace/firewall capabilities
|
|
121
|
+
* (root, or CAP_NET_ADMIN + CAP_SYS_ADMIN). Probed by the supervisor; when
|
|
122
|
+
* false the firewall is a graceful no-op (the netns/nft syscalls would EPERM).
|
|
123
|
+
*/
|
|
124
|
+
hasCaps: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The skip outcome: the guest spawns unchanged; the supervisor logs `reason`. */
|
|
128
|
+
export interface EgressFirewallSkip {
|
|
129
|
+
applied: false;
|
|
130
|
+
/** Human-readable why-skipped, logged once by the supervisor as a notice. */
|
|
131
|
+
reason: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The apply outcome: the supervisor wraps the guest argv with {@link wrapWithNetns}. */
|
|
135
|
+
export interface EgressFirewallApply {
|
|
136
|
+
applied: true;
|
|
137
|
+
/** The nftables ruleset to load inside the guest's netns (an `nft -f -` script). */
|
|
138
|
+
nftRuleset: string;
|
|
139
|
+
/** Count of accept rules emitted (diagnostic; 0 = pure default-deny). */
|
|
140
|
+
allowCount: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type EgressFirewallPlan = EgressFirewallSkip | EgressFirewallApply;
|
|
144
|
+
|
|
145
|
+
/** True only for Linux. macOS (`darwin`) / Windows (`windows`) → false. */
|
|
146
|
+
export function isLinux(os: string): boolean {
|
|
147
|
+
return os === "linux";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `process.platform` → `Deno.build.os` vocabulary, so the supervisor and any
|
|
152
|
+
* Bun/Node probe agree on one OS string. Deno uses `darwin` / `windows` / `linux`.
|
|
153
|
+
*/
|
|
154
|
+
export function normalizeOs(platform: string): string {
|
|
155
|
+
if (platform === "win32") return "windows";
|
|
156
|
+
return platform; // darwin, linux already match Deno's vocabulary
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Basic IPv4-literal test (mirrors net-validation, kept local to stay pure). */
|
|
160
|
+
function looksIpv4(ip: string): boolean {
|
|
161
|
+
const parts = ip.split(".");
|
|
162
|
+
return (
|
|
163
|
+
parts.length === 4 &&
|
|
164
|
+
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the nftables ruleset (an `nft -f -` script) for ONE run.
|
|
170
|
+
*
|
|
171
|
+
* Shape (an `inet` table → one `output` hook chain, default policy DROP):
|
|
172
|
+
* - ACCEPT established/related + loopback `lo` (DNS/own-process plumbing).
|
|
173
|
+
* - DROP every {@link BLOCKED_IPV4_CIDRS} / {@link BLOCKED_IPV6_CIDRS} dest.
|
|
174
|
+
* (Listed BEFORE the allowlist so a misclassified allow can never re-open a
|
|
175
|
+
* private range — drop wins.)
|
|
176
|
+
* - ACCEPT each resolved {@link AllowedEndpoint} (the per-run allowlist +
|
|
177
|
+
* broker), pinned to its validated public IP (and port, if given).
|
|
178
|
+
* - Final implicit DROP via the chain's `policy drop`.
|
|
179
|
+
*
|
|
180
|
+
* Deterministic: same input → byte-identical output, so it is unit-testable.
|
|
181
|
+
*/
|
|
182
|
+
export function buildNftRuleset(allow: readonly AllowedEndpoint[]): string {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
lines.push(`# QUESTPIE sandbox egress firewall (defense-in-depth, per-run).`);
|
|
185
|
+
lines.push(`# Default-deny: only the resolved allowlist + broker may egress.`);
|
|
186
|
+
lines.push(`table inet ${NFT_TABLE} {`);
|
|
187
|
+
lines.push(`\tchain output {`);
|
|
188
|
+
lines.push(`\t\ttype filter hook output priority 0; policy drop;`);
|
|
189
|
+
// Keep own-process plumbing + reply traffic working.
|
|
190
|
+
lines.push(`\t\tct state established,related accept`);
|
|
191
|
+
lines.push(`\t\toif "lo" accept`);
|
|
192
|
+
|
|
193
|
+
// DROP the private/metadata space FIRST — drop must win over any allow.
|
|
194
|
+
for (const cidr of BLOCKED_IPV4_CIDRS) {
|
|
195
|
+
lines.push(`\t\tip daddr ${cidr} drop`);
|
|
196
|
+
}
|
|
197
|
+
for (const cidr of BLOCKED_IPV6_CIDRS) {
|
|
198
|
+
lines.push(`\t\tip6 daddr ${cidr} drop`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ACCEPT the resolved per-run allowlist (public IPs only — already validated).
|
|
202
|
+
for (const ep of allow) {
|
|
203
|
+
const fam = looksIpv4(ep.ip) ? "ip" : "ip6";
|
|
204
|
+
const portClause =
|
|
205
|
+
typeof ep.port === "number" && Number.isInteger(ep.port)
|
|
206
|
+
? ` tcp dport ${ep.port}`
|
|
207
|
+
: "";
|
|
208
|
+
lines.push(`\t\t${fam} daddr ${ep.ip}${portClause} accept`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
lines.push(`\t}`);
|
|
212
|
+
lines.push(`}`);
|
|
213
|
+
return lines.join("\n") + "\n";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Decide whether to apply the firewall for this run, and build its artifacts.
|
|
218
|
+
*
|
|
219
|
+
* Returns `{ applied:false, reason }` (graceful no-op) when:
|
|
220
|
+
* - the OS is not Linux (macOS / Windows local workers), OR
|
|
221
|
+
* - a required tool (`unshare`/`nft`/`ip`) is missing, OR
|
|
222
|
+
* - the supervisor lacks the netns/firewall capabilities.
|
|
223
|
+
*
|
|
224
|
+
* Otherwise returns `{ applied:true, nftRuleset, allowCount }`; the supervisor
|
|
225
|
+
* then wraps the guest argv with {@link wrapWithNetns}.
|
|
226
|
+
*
|
|
227
|
+
* NOTE: an empty `allow` is VALID and still applies on Linux — it yields a pure
|
|
228
|
+
* default-deny netns, the correct posture for the brokered (`--allow-net=[]`)
|
|
229
|
+
* path. The guest has nothing to reach over raw sockets; the kernel enforces it.
|
|
230
|
+
*/
|
|
231
|
+
export function planEgressFirewall(input: EgressFirewallPlanInput): EgressFirewallPlan {
|
|
232
|
+
if (!isLinux(input.os)) {
|
|
233
|
+
return {
|
|
234
|
+
applied: false,
|
|
235
|
+
reason: `non-Linux platform (${input.os}); netns+nftables egress firewall unavailable — baseline stays safe via brokered fetch`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const missing: string[] = [];
|
|
239
|
+
if (!input.tools.unshare) missing.push("unshare");
|
|
240
|
+
if (!input.tools.nft) missing.push("nft");
|
|
241
|
+
if (!input.tools.ip) missing.push("ip");
|
|
242
|
+
if (missing.length > 0) {
|
|
243
|
+
return {
|
|
244
|
+
applied: false,
|
|
245
|
+
reason: `required tool(s) not found on PATH: ${missing.join(", ")} — skipping kernel egress firewall (brokered fetch still enforces SSRF policy)`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (!input.hasCaps) {
|
|
249
|
+
return {
|
|
250
|
+
applied: false,
|
|
251
|
+
reason: `supervisor lacks CAP_NET_ADMIN/CAP_SYS_ADMIN (not root / no caps) — netns+nftables cannot be created; skipping (brokered fetch still enforces SSRF policy)`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
applied: true,
|
|
256
|
+
nftRuleset: buildNftRuleset(input.allow),
|
|
257
|
+
allowCount: input.allow.length,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Wrap a guest `deno run …` argv so it executes inside a fresh NETWORK
|
|
263
|
+
* NAMESPACE with the egress firewall loaded, via a small bootstrap run under
|
|
264
|
+
* `unshare`.
|
|
265
|
+
*
|
|
266
|
+
* The produced argv is:
|
|
267
|
+
*
|
|
268
|
+
* unshare --net --map-root-user -- /bin/sh -c '<bootstrap> && exec "$@"' -- <guest argv…>
|
|
269
|
+
*
|
|
270
|
+
* where `<bootstrap>` brings up loopback (`ip link set lo up`), loads the
|
|
271
|
+
* per-run ruleset (`nft -f <rulesetPath>`), then `exec`s the unchanged guest
|
|
272
|
+
* command. Running the guest as PID-in-its-own-netns means killing the
|
|
273
|
+
* supervisor's child still reaps it (the existing SIGTERM/SIGKILL path is
|
|
274
|
+
* unchanged — this only prefixes the command).
|
|
275
|
+
*
|
|
276
|
+
* `--map-root-user` lets an UNPRIVILEGED supervisor create the netns via a user
|
|
277
|
+
* namespace where it is root *inside* (modern kernels with unprivileged userns).
|
|
278
|
+
* On hosts where that is disabled, the supervisor's cap probe returns false and
|
|
279
|
+
* we never get here — the firewall no-ops instead (see {@link planEgressFirewall}).
|
|
280
|
+
*
|
|
281
|
+
* IMPORTANT: this is a PURE argv transform (no syscalls), so it is unit-testable
|
|
282
|
+
* off Linux. The actual namespace/firewall effect is exercised only on Linux.
|
|
283
|
+
*
|
|
284
|
+
* @param guestArgv the original `[DENO_BIN, "run", …flags, entry]` the
|
|
285
|
+
* supervisor would have spawned.
|
|
286
|
+
* @param rulesetPath absolute path to the ruleset file the supervisor wrote
|
|
287
|
+
* (from {@link EgressFirewallApply.nftRuleset}).
|
|
288
|
+
* @returns `{ cmd, args }` for `new Deno.Command(cmd, { args, … })`.
|
|
289
|
+
*/
|
|
290
|
+
export function wrapWithNetns(
|
|
291
|
+
guestArgv: readonly string[],
|
|
292
|
+
rulesetPath: string,
|
|
293
|
+
): { cmd: string; args: string[] } {
|
|
294
|
+
// Bootstrap that runs INSIDE the new netns before the guest:
|
|
295
|
+
// 0. set a deterministic PATH so `nft`/`ip` resolve. The supervisor spawns
|
|
296
|
+
// `unshare` with `clearEnv:true`, so the inner shell would otherwise get
|
|
297
|
+
// execvp's default PATH (no `/sbin` or `/usr/sbin`, where nft/ip usually
|
|
298
|
+
// live) → "command not found". Pinning PATH here is robust + deterministic.
|
|
299
|
+
// 1. bring loopback up (so the guest's own intra-process plumbing works),
|
|
300
|
+
// 2. load the egress ruleset,
|
|
301
|
+
// 3. exec the guest argv (passed as positional "$@").
|
|
302
|
+
// `set -e` so a failed nft load aborts BEFORE the guest runs (fail-closed:
|
|
303
|
+
// we never run the guest in a netns whose firewall did not load).
|
|
304
|
+
const bootstrap = [
|
|
305
|
+
"set -e",
|
|
306
|
+
"PATH=/usr/sbin:/usr/bin:/sbin:/bin:$PATH",
|
|
307
|
+
"ip link set lo up",
|
|
308
|
+
`nft -f ${shQuote(rulesetPath)}`,
|
|
309
|
+
'exec "$@"',
|
|
310
|
+
].join("; ");
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
cmd: "unshare",
|
|
314
|
+
args: [
|
|
315
|
+
"--net", // fresh network namespace (no interfaces but lo)
|
|
316
|
+
"--map-root-user", // root-inside via userns for unprivileged hosts
|
|
317
|
+
"--",
|
|
318
|
+
"/bin/sh",
|
|
319
|
+
"-c",
|
|
320
|
+
bootstrap,
|
|
321
|
+
"sh", // $0 for the inner shell
|
|
322
|
+
...guestArgv, // become "$@" → exec'd as the guest
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Minimal POSIX single-quote escaping for embedding a path in the `sh -c` bootstrap. */
|
|
328
|
+
function shQuote(s: string): string {
|
|
329
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
330
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guest bindings proxy — the UNTRUSTED-side typed surface a sandboxed mini-app
|
|
3
|
+
* uses to call the app's primitives WITHOUT importing the app.
|
|
4
|
+
*
|
|
5
|
+
* Design: `.private/knowledge-miniapps-mvp.md` §13 (TRUSTED vs UNTRUSTED) + §14
|
|
6
|
+
* (the primitive surface). Mirrors the server `ctx` names (consistent-naming
|
|
7
|
+
* principle) so guest code reads like a server handler:
|
|
8
|
+
*
|
|
9
|
+
* const orders = await globalThis.questpie.collections.orders.find({ where });
|
|
10
|
+
* const note = await globalThis.questpie.files.read({ path });
|
|
11
|
+
*
|
|
12
|
+
* Every method just does `await hostCall(method, args)`. `hostCall` is INJECTED
|
|
13
|
+
* (it writes a framed RPC to the supervisor over stdio and awaits the brokered
|
|
14
|
+
* result) — this module holds NO transport and NO secrets, so it is pure and
|
|
15
|
+
* unit-testable with a fake `hostCall`. The guest never sees the per-run token
|
|
16
|
+
* or a broker URL; the only thing it can do is call these methods, which the
|
|
17
|
+
* trusted host broker enforces per call (default-deny).
|
|
18
|
+
*
|
|
19
|
+
* This file is plain TS (no Deno globals) so it typechecks + unit-tests in Bun;
|
|
20
|
+
* `guest-entry.ts` (Deno) imports it and supplies the real stdio `hostCall`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Transport: send one binding RPC, resolve with its brokered value or reject. */
|
|
24
|
+
export type HostCall = (method: string, args: unknown) => Promise<unknown>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-collection access surface. Reads (`find`/`findOne`) are always available;
|
|
28
|
+
* writes (`create`/`update`/`delete`) are dispatched ONLY where the host wires a
|
|
29
|
+
* safe handler (the `document_store` store-grant clamp, or a collection with an
|
|
30
|
+
* explicit write rule — Decision 8 / §7). An ungranted/unwired call rejects host-side.
|
|
31
|
+
*/
|
|
32
|
+
export interface GuestCollection {
|
|
33
|
+
find(args?: unknown): Promise<unknown>;
|
|
34
|
+
findOne(args?: unknown): Promise<unknown>;
|
|
35
|
+
create(args?: unknown): Promise<unknown>;
|
|
36
|
+
update(args?: unknown): Promise<unknown>;
|
|
37
|
+
delete(args?: unknown): Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sugar surface for ONE `document_store` namespace. Every op auto-injects
|
|
42
|
+
* `store: <name>`; the host still clamps to the run's granted stores (the sugar is
|
|
43
|
+
* a convenience, NOT a second enforcement path — `questpie.store.posts.create(x)`
|
|
44
|
+
* is exactly `questpie.collections.document_store.create({ ...x, store:"posts" })`).
|
|
45
|
+
*/
|
|
46
|
+
export interface GuestStore {
|
|
47
|
+
find(args?: Record<string, unknown>): Promise<unknown>;
|
|
48
|
+
findOne(args?: Record<string, unknown>): Promise<unknown>;
|
|
49
|
+
create(args: { key: string; data?: unknown }): Promise<unknown>;
|
|
50
|
+
update(args: {
|
|
51
|
+
where?: unknown;
|
|
52
|
+
data?: Record<string, unknown>;
|
|
53
|
+
}): Promise<unknown>;
|
|
54
|
+
delete(args?: { where?: unknown }): Promise<unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `globalThis.questpie` surface handed to untrusted guest code. */
|
|
58
|
+
export interface GuestBindings {
|
|
59
|
+
/** Files (file-as-DB), scoped by the run's `capabilities.files`. */
|
|
60
|
+
files: {
|
|
61
|
+
read(args: { path: string; scope?: unknown }): Promise<unknown>;
|
|
62
|
+
write(args: {
|
|
63
|
+
path: string;
|
|
64
|
+
content: string;
|
|
65
|
+
title?: string;
|
|
66
|
+
mime_type?: string;
|
|
67
|
+
scope?: unknown;
|
|
68
|
+
}): Promise<unknown>;
|
|
69
|
+
list(args?: { path?: string; scope?: unknown }): Promise<unknown>;
|
|
70
|
+
};
|
|
71
|
+
/** Per-collection read/write access, scoped by `capabilities.data.collections`. */
|
|
72
|
+
collections: Record<string, GuestCollection>;
|
|
73
|
+
/**
|
|
74
|
+
* Per-namespace `document_store` sugar, scoped by `capabilities.data.stores`.
|
|
75
|
+
* `questpie.store.<name>.<op>` injects `store:<name>` onto the underlying
|
|
76
|
+
* `collections.document_store.<op>` call.
|
|
77
|
+
*/
|
|
78
|
+
store: Record<string, GuestStore>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the guest bindings proxy over an injected {@link HostCall}.
|
|
83
|
+
*
|
|
84
|
+
* `collections` is a Proxy so any collection NAME resolves to a `{ find,
|
|
85
|
+
* findOne }` shim — capability enforcement for the specific collection happens
|
|
86
|
+
* HOST-SIDE in the broker (the guest can't pre-know which names are granted, and
|
|
87
|
+
* the proxy must not leak that). An out-of-scope collection call simply rejects
|
|
88
|
+
* when the host denies it.
|
|
89
|
+
*/
|
|
90
|
+
export function buildGuestBindings(hostCall: HostCall): GuestBindings {
|
|
91
|
+
const files: GuestBindings["files"] = {
|
|
92
|
+
read: (args) => hostCall("files.read", args),
|
|
93
|
+
write: (args) => hostCall("files.write", args),
|
|
94
|
+
list: (args) => hostCall("files.list", args ?? {}),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const collections = new Proxy(
|
|
98
|
+
{},
|
|
99
|
+
{
|
|
100
|
+
get(_t, name: string | symbol): GuestCollection | undefined {
|
|
101
|
+
if (typeof name !== "string") return undefined;
|
|
102
|
+
return {
|
|
103
|
+
find: (args) => hostCall(`collections.${name}.find`, args ?? {}),
|
|
104
|
+
findOne: (args) =>
|
|
105
|
+
hostCall(`collections.${name}.findOne`, args ?? {}),
|
|
106
|
+
create: (args) => hostCall(`collections.${name}.create`, args ?? {}),
|
|
107
|
+
update: (args) => hostCall(`collections.${name}.update`, args ?? {}),
|
|
108
|
+
delete: (args) => hostCall(`collections.${name}.delete`, args ?? {}),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
) as Record<string, GuestCollection>;
|
|
113
|
+
|
|
114
|
+
// `store.<name>` sugar → `collections.document_store.<op>` scoped to ONE store.
|
|
115
|
+
// CREATE injects a top-level `store` (the row field the host stamps/validates);
|
|
116
|
+
// FIND/UPDATE/DELETE inject `where.store` (so the call targets only that store —
|
|
117
|
+
// the host then ANDs it with `store ∈ granted`). Enforcement stays HOST-SIDE
|
|
118
|
+
// (the store-grant clamp); this is a pure convenience, so an ungranted store
|
|
119
|
+
// still rejects host-side.
|
|
120
|
+
const store = new Proxy(
|
|
121
|
+
{},
|
|
122
|
+
{
|
|
123
|
+
get(_t, name: string | symbol): GuestStore | undefined {
|
|
124
|
+
if (typeof name !== "string") return undefined;
|
|
125
|
+
const withStoreWhere = (args: Record<string, unknown>) => {
|
|
126
|
+
const where =
|
|
127
|
+
args.where && typeof args.where === "object"
|
|
128
|
+
? { ...(args.where as Record<string, unknown>), store: name }
|
|
129
|
+
: { store: name };
|
|
130
|
+
return { ...args, where };
|
|
131
|
+
};
|
|
132
|
+
const ds = (op: string, args: unknown) =>
|
|
133
|
+
hostCall(`collections.document_store.${op}`, args);
|
|
134
|
+
return {
|
|
135
|
+
find: (args = {}) => ds("find", withStoreWhere(args)),
|
|
136
|
+
findOne: (args = {}) => ds("findOne", withStoreWhere(args)),
|
|
137
|
+
create: (args) => ds("create", { ...(args ?? {}), store: name }),
|
|
138
|
+
update: (args) => ds("update", withStoreWhere(args ?? {})),
|
|
139
|
+
delete: (args = {}) => ds("delete", withStoreWhere(args)),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
) as Record<string, GuestStore>;
|
|
144
|
+
|
|
145
|
+
return { files, collections, store };
|
|
146
|
+
}
|