@namzu/sandbox 1.1.0 → 2.0.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/CHANGELOG.md +277 -0
- package/README.md +205 -105
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +2 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +1 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +61 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +1 -0
- package/dist/backends/aci-standby-pool/index.d.ts +2 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +36 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/__tests__/hardening.test.d.ts +2 -0
- package/dist/backends/docker/__tests__/hardening.test.d.ts.map +1 -0
- package/dist/backends/docker/__tests__/hardening.test.js +32 -0
- package/dist/backends/docker/__tests__/hardening.test.js.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +1 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +1 -1
- package/dist/backends/docker/index.d.ts +47 -3
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +144 -5
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +16 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +37 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.js +11 -3
- package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +10 -2
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +2 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js +67 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +21 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +30 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js +7 -17
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/transport.test.js +11 -3
- package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts +20 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +70 -15
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/egress/__tests__/allowlist.test.d.ts +2 -0
- package/dist/egress/__tests__/allowlist.test.d.ts.map +1 -0
- package/dist/egress/__tests__/allowlist.test.js +85 -0
- package/dist/egress/__tests__/allowlist.test.js.map +1 -0
- package/dist/egress/__tests__/proxy.test.d.ts +2 -0
- package/dist/egress/__tests__/proxy.test.d.ts.map +1 -0
- package/dist/egress/__tests__/proxy.test.js +177 -0
- package/dist/egress/__tests__/proxy.test.js.map +1 -0
- package/dist/egress/allowlist.d.ts +40 -0
- package/dist/egress/allowlist.d.ts.map +1 -0
- package/dist/egress/allowlist.js +81 -0
- package/dist/egress/allowlist.js.map +1 -0
- package/dist/egress/index.d.ts +4 -0
- package/dist/egress/index.d.ts.map +1 -0
- package/dist/egress/index.js +3 -0
- package/dist/egress/index.js.map +1 -0
- package/dist/egress/proxy.d.ts +90 -0
- package/dist/egress/proxy.d.ts.map +1 -0
- package/dist/egress/proxy.js +194 -0
- package/dist/egress/proxy.js.map +1 -0
- package/dist/index.d.ts +101 -190
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -80
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +18 -39
- package/dist/index.test.js.map +1 -1
- package/package.json +5 -4
- package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +69 -0
- package/src/backends/aci-standby-pool/index.ts +42 -1
- package/src/backends/docker/__tests__/hardening.test.ts +43 -0
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +1 -1
- package/src/backends/docker/index.ts +210 -12
- package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +48 -0
- package/src/backends/firecracker/__tests__/backend.test.ts +76 -65
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +10 -2
- package/src/backends/firecracker/__tests__/egress-policy.test.ts +91 -0
- package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +31 -0
- package/src/backends/firecracker/__tests__/protocol.test.ts +8 -23
- package/src/backends/firecracker/__tests__/transport.test.ts +11 -3
- package/src/backends/firecracker/index.ts +76 -13
- package/src/egress/__tests__/allowlist.test.ts +103 -0
- package/src/egress/__tests__/proxy.test.ts +212 -0
- package/src/egress/allowlist.ts +82 -0
- package/src/egress/index.ts +7 -0
- package/src/egress/proxy.ts +294 -0
- package/src/index.test.ts +19 -41
- package/src/index.ts +170 -259
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A local IPC address Node can actually `listen()` on, for the current OS.
|
|
6
|
+
*
|
|
7
|
+
* These suites bind a loopback agent over a Unix domain socket in a temp
|
|
8
|
+
* directory. Windows supports `AF_UNIX` at the OS level, but Node's `net`
|
|
9
|
+
* server does not bind filesystem sockets there — it wants a **named
|
|
10
|
+
* pipe**, so every one of these tests failed with
|
|
11
|
+
* `listen EACCES: permission denied …\agent.sock` and the whole
|
|
12
|
+
* `@namzu/sandbox` suite has been red for Windows contributors.
|
|
13
|
+
*
|
|
14
|
+
* The transport under test is address-agnostic: it is handed a path and
|
|
15
|
+
* connects to it. So the fix belongs in the fixture, not the driver.
|
|
16
|
+
*/
|
|
17
|
+
export function localIpcPath(workDir: string, name = 'agent'): string {
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
// Named pipes are not filesystem entries, so the temp dir cannot
|
|
20
|
+
// namespace them — a uuid keeps concurrent test files apart.
|
|
21
|
+
return `\\\\.\\pipe\\namzu-${name}-${randomUUID()}`
|
|
22
|
+
}
|
|
23
|
+
return join(workDir, `${name}.sock`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when the socket address lives on disk and can be cleaned up with
|
|
28
|
+
* the temp directory. Named pipes disappear with the process that owns
|
|
29
|
+
* them, so there is nothing to unlink.
|
|
30
|
+
*/
|
|
31
|
+
export const IPC_IS_FILESYSTEM_PATH = process.platform !== 'win32'
|
|
@@ -50,9 +50,6 @@ describe('pickBackend — microvm:self-hosted', () => {
|
|
|
50
50
|
backend: {
|
|
51
51
|
tier: 'microvm',
|
|
52
52
|
service: 'self-hosted',
|
|
53
|
-
firecrackerBinary: '/usr/bin/firecracker',
|
|
54
|
-
kernelImage: '/golden/vmlinux',
|
|
55
|
-
rootfsImage: '/golden/rootfs.ext4',
|
|
56
53
|
orchestratorEndpoint: 'https://orchestrator.test',
|
|
57
54
|
getToken: async () => 'tok',
|
|
58
55
|
template: 'golden-rev-1',
|
|
@@ -63,28 +60,16 @@ describe('pickBackend — microvm:self-hosted', () => {
|
|
|
63
60
|
expect(provider.name).toContain('microvm:self-hosted')
|
|
64
61
|
})
|
|
65
62
|
|
|
66
|
-
it('
|
|
63
|
+
it('refuses the shape that reaches no orchestrator', () => {
|
|
64
|
+
// The control-plane endpoint and its bearer are required now. They
|
|
65
|
+
// used to be optional beside three REQUIRED fields belonging to a
|
|
66
|
+
// local-daemon path that was never written, so the only working
|
|
67
|
+
// configuration had to supply three values nothing reads — and
|
|
68
|
+
// omitting these two type-checked its way to a runtime throw.
|
|
67
69
|
expect(() =>
|
|
68
70
|
createSandboxProvider({
|
|
69
|
-
backend: {
|
|
70
|
-
|
|
71
|
-
service: 'self-hosted',
|
|
72
|
-
firecrackerBinary: '/usr/bin/firecracker',
|
|
73
|
-
kernelImage: '/k',
|
|
74
|
-
rootfsImage: '/r',
|
|
75
|
-
},
|
|
76
|
-
}),
|
|
77
|
-
).toThrow(SandboxBackendNotImplementedError)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('still throws for microvm:e2b and microvm:fly-machines', () => {
|
|
81
|
-
expect(() =>
|
|
82
|
-
createSandboxProvider({ backend: { tier: 'microvm', service: 'e2b', apiKey: 'k' } }),
|
|
83
|
-
).toThrow(SandboxBackendNotImplementedError)
|
|
84
|
-
expect(() =>
|
|
85
|
-
createSandboxProvider({
|
|
86
|
-
backend: { tier: 'microvm', service: 'fly-machines', apiToken: 't', app: 'a', image: 'i' },
|
|
87
|
-
}),
|
|
71
|
+
backend: { tier: 'microvm', service: 'self-hosted' },
|
|
72
|
+
} as never),
|
|
88
73
|
).toThrow(SandboxBackendNotImplementedError)
|
|
89
74
|
})
|
|
90
75
|
})
|
|
@@ -31,6 +31,14 @@ import { VsockAgentTransport } from '../transport.js'
|
|
|
31
31
|
// require-time. Set the env, then require it through createRequire so
|
|
32
32
|
// each test file gets a fresh module bound to its own workspace dir.
|
|
33
33
|
import { createRequire } from 'node:module'
|
|
34
|
+
import { localIpcPath } from './fixtures/ipc-path.js'
|
|
35
|
+
// The loopback agent under test is the guest-side agent for a Linux microVM:
|
|
36
|
+
// it spawns `/bin/sh` to run commands, and that binary does not exist on
|
|
37
|
+
// Windows. These cases assert behavior the platform cannot produce, so they
|
|
38
|
+
// skip there rather than leaving the suite permanently red for Windows
|
|
39
|
+
// contributors. The socket-address fixture IS platform-correct, so the
|
|
40
|
+
// transport is still exercised wherever it can be.
|
|
41
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
34
42
|
|
|
35
43
|
const require_ = createRequire(import.meta.url)
|
|
36
44
|
|
|
@@ -53,7 +61,7 @@ function startAgentServer(connHandler: (s: Socket) => void): Promise<Server> {
|
|
|
53
61
|
|
|
54
62
|
beforeEach(() => {
|
|
55
63
|
workDir = mkdtempSync(join(tmpdir(), 'fc-agent-test-'))
|
|
56
|
-
sockPath =
|
|
64
|
+
sockPath = localIpcPath(workDir)
|
|
57
65
|
// Bind the agent's workspace jail to the temp dir BEFORE requiring it.
|
|
58
66
|
process.env.NAMZU_SANDBOX_WORKSPACE = workDir
|
|
59
67
|
// The agent's root-normalization filters env on PRESENCE; `= undefined`
|
|
@@ -76,7 +84,7 @@ afterEach(async () => {
|
|
|
76
84
|
rmSync(workDir, { recursive: true, force: true })
|
|
77
85
|
})
|
|
78
86
|
|
|
79
|
-
describe('VsockAgentTransport over a unix-socket loopback agent', () => {
|
|
87
|
+
describe.skipIf(IS_WINDOWS)('VsockAgentTransport over a unix-socket loopback agent', () => {
|
|
80
88
|
it('streams stdout/stderr/result NDJSON from an exec', async () => {
|
|
81
89
|
server = await startAgentServer(agent.handleConnection)
|
|
82
90
|
const transport = new VsockAgentTransport({ kind: 'unix', path: sockPath })
|
|
@@ -367,7 +375,7 @@ function startMtlsRelayOnPort(agentSockPath: string, port: number): Promise<Rela
|
|
|
367
375
|
return startMtlsRelay(agentSockPath, port)
|
|
368
376
|
}
|
|
369
377
|
|
|
370
|
-
describe('VsockAgentTransport mtls arm over a TLS loopback relay', () => {
|
|
378
|
+
describe.skipIf(IS_WINDOWS)('VsockAgentTransport mtls arm over a TLS loopback relay', () => {
|
|
371
379
|
let agentServer: Server | undefined
|
|
372
380
|
let relay: RelayHandle | undefined
|
|
373
381
|
|
|
@@ -65,6 +65,21 @@ import type {
|
|
|
65
65
|
} from './transport.js'
|
|
66
66
|
import { VsockAgentTransport } from './transport.js'
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Trim trailing slashes without a regex.
|
|
70
|
+
*
|
|
71
|
+
* `/\/+$/` backtracks quadratically on a long run of slashes, and this
|
|
72
|
+
* value crosses a trust boundary — a host-supplied endpoint on a shared
|
|
73
|
+
* event loop. The scan is linear and says the same thing.
|
|
74
|
+
*/
|
|
75
|
+
function stripTrailingSlashes(value: string): string {
|
|
76
|
+
let end = value.length
|
|
77
|
+
while (end > 0 && value[end - 1] === '/') {
|
|
78
|
+
end--
|
|
79
|
+
}
|
|
80
|
+
return value.slice(0, end)
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
/**
|
|
69
84
|
* Async callback returning a fresh bearer token for
|
|
70
85
|
* {@link FirecrackerBackendInternalConfig.orchestratorEndpoint}.
|
|
@@ -195,7 +210,7 @@ async function orchestratorCall<T>(
|
|
|
195
210
|
mtls?: MtlsClientMaterial,
|
|
196
211
|
): Promise<T | undefined> {
|
|
197
212
|
const token = await getToken()
|
|
198
|
-
const url = `${endpoint
|
|
213
|
+
const url = `${stripTrailingSlashes(endpoint)}${pathSuffix}`
|
|
199
214
|
const payload = body !== undefined ? JSON.stringify(body) : undefined
|
|
200
215
|
const headers: Record<string, string> = {
|
|
201
216
|
Authorization: `Bearer ${token}`,
|
|
@@ -308,15 +323,17 @@ async function spawnFirecrackerSandbox(
|
|
|
308
323
|
options: SandboxBackendOptions,
|
|
309
324
|
): Promise<Sandbox> {
|
|
310
325
|
const endpoint = config.orchestratorEndpoint
|
|
326
|
+
const egressAllowlist = await resolveEgressAllowlist(options)
|
|
311
327
|
const createBody: OrchestratorCreateRequest = {
|
|
312
328
|
...(config.template !== undefined ? { template: config.template } : {}),
|
|
313
329
|
...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
|
|
314
330
|
...(options.memoryLimitMb !== undefined ? { memoryLimitMb: options.memoryLimitMb } : {}),
|
|
315
331
|
...(options.maxProcesses !== undefined ? { maxProcesses: options.maxProcesses } : {}),
|
|
316
332
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
// Resolved once. It used to be called twice — harmless for the pure
|
|
334
|
+
// kinds, and once the resolver variant actually runs its callback,
|
|
335
|
+
// twice means two round-trips and two chances to disagree.
|
|
336
|
+
...(egressAllowlist !== undefined ? { egressAllowlist } : {}),
|
|
320
337
|
}
|
|
321
338
|
|
|
322
339
|
let created: OrchestratorCreateResponse | undefined
|
|
@@ -404,6 +421,16 @@ async function spawnFirecrackerSandbox(
|
|
|
404
421
|
argv?: string[],
|
|
405
422
|
opts?: SandboxExecOptions,
|
|
406
423
|
): Promise<SandboxExecResult> {
|
|
424
|
+
// `opts.signal` is deliberately not forwarded, and the reason is
|
|
425
|
+
// worth writing down because the obvious "fix" is worse than the
|
|
426
|
+
// gap. There is no cancel op on this wire — the guest agent takes
|
|
427
|
+
// an execute frame and answers when the command is done. Aborting
|
|
428
|
+
// the socket here would abandon the WAIT while the process keeps
|
|
429
|
+
// running inside the microVM, which is verbatim the failure
|
|
430
|
+
// `SandboxExecOptions.signal` exists to prevent, except it would
|
|
431
|
+
// then look honoured. Honouring it means a cancel op in the guest
|
|
432
|
+
// protocol; until then, ignoring it is the truthful behaviour the
|
|
433
|
+
// option's own contract allows.
|
|
407
434
|
status = 'busy'
|
|
408
435
|
try {
|
|
409
436
|
return await transport.execute({
|
|
@@ -463,17 +490,53 @@ async function spawnFirecrackerSandbox(
|
|
|
463
490
|
// Helpers
|
|
464
491
|
// ---------------------------------------------------------------------------
|
|
465
492
|
|
|
466
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Materialise an egress policy into the allowlist the orchestrator turns
|
|
495
|
+
* into firewall rules.
|
|
496
|
+
*
|
|
497
|
+
* Omitting the field means "no allowlist to apply", which the orchestrator
|
|
498
|
+
* reads as unrestricted. That makes omission the encoding for `allow-all`
|
|
499
|
+
* and ONLY for `allow-all`.
|
|
500
|
+
*
|
|
501
|
+
* `resolver` used to be omitted too, so two opposite intentions shared one
|
|
502
|
+
* encoding and the tenant-scoped allowlist the variant exists for was
|
|
503
|
+
* silently absent — with the callback that would have produced it never
|
|
504
|
+
* called anywhere in the repo. Whichever way the orchestrator reads an
|
|
505
|
+
* omitted field, one of the two variants was always mis-enforced, and the
|
|
506
|
+
* one that failed open was the one whose entire purpose is restriction.
|
|
507
|
+
*
|
|
508
|
+
* The switch is exhaustive on purpose: a new variant should fail to
|
|
509
|
+
* compile here rather than fall through to "unrestricted".
|
|
510
|
+
*/
|
|
511
|
+
export async function resolveEgressAllowlist(
|
|
512
|
+
options: SandboxBackendOptions,
|
|
513
|
+
): Promise<readonly string[] | undefined> {
|
|
467
514
|
const egress = options.egress
|
|
468
515
|
if (!egress) return undefined
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
516
|
+
|
|
517
|
+
switch (egress.kind) {
|
|
518
|
+
case 'allow-all':
|
|
519
|
+
// The one intent omission is allowed to mean.
|
|
520
|
+
return undefined
|
|
521
|
+
case 'deny-all':
|
|
522
|
+
// Explicitly empty, not absent.
|
|
523
|
+
return []
|
|
524
|
+
case 'static':
|
|
525
|
+
return egress.allowedHosts
|
|
526
|
+
case 'resolver': {
|
|
527
|
+
// The callback exists to produce this list. Calling it is the
|
|
528
|
+
// whole feature; an empty result is a real deny-all and travels
|
|
529
|
+
// as one rather than collapsing back to omission.
|
|
530
|
+
const resolved = await egress.resolve()
|
|
531
|
+
return resolved
|
|
532
|
+
}
|
|
533
|
+
default: {
|
|
534
|
+
const exhaustive: never = egress
|
|
535
|
+
throw new Error(
|
|
536
|
+
`Unhandled egress policy kind: ${JSON.stringify(exhaustive)}. Refusing rather than defaulting to unrestricted network access.`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
477
540
|
}
|
|
478
541
|
|
|
479
542
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isHostAllowed, splitAuthority } from '../allowlist.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The part that decides whether untrusted code reaches the network.
|
|
7
|
+
*
|
|
8
|
+
* Substring matching is the obvious implementation and it is a hole: an
|
|
9
|
+
* entry of `example.com` would admit `example.com.attacker.net`, a domain
|
|
10
|
+
* the attacker owns. Plain suffix matching has the same hole without a
|
|
11
|
+
* leading dot — `notexample.com` ends with `example.com`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe('an exact host entry', () => {
|
|
15
|
+
it('matches only that host', () => {
|
|
16
|
+
expect(isHostAllowed('api.example.com', ['api.example.com'])).toBe(true)
|
|
17
|
+
expect(isHostAllowed('other.example.com', ['api.example.com'])).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does not match a host that merely contains it', () => {
|
|
21
|
+
// The whole reason this is not `includes`.
|
|
22
|
+
expect(isHostAllowed('api.example.com.attacker.net', ['api.example.com'])).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not match a prefix of itself', () => {
|
|
26
|
+
expect(isHostAllowed('example.com', ['api.example.com'])).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('a wildcard entry', () => {
|
|
31
|
+
it('matches subdomains', () => {
|
|
32
|
+
expect(isHostAllowed('api.example.com', ['.example.com'])).toBe(true)
|
|
33
|
+
expect(isHostAllowed('deep.api.example.com', ['.example.com'])).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('matches the apex too', () => {
|
|
37
|
+
// An author writing the wildcard form means the site; admitting
|
|
38
|
+
// `www.example.com` but not `example.com` reads as a bug.
|
|
39
|
+
expect(isHostAllowed('example.com', ['.example.com'])).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not match a domain that merely ends with the same letters', () => {
|
|
43
|
+
// This is why the leading dot is required rather than optional.
|
|
44
|
+
expect(isHostAllowed('notexample.com', ['.example.com'])).toBe(false)
|
|
45
|
+
expect(isHostAllowed('evilexample.com', ['.example.com'])).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not match a domain that only contains it', () => {
|
|
49
|
+
expect(isHostAllowed('example.com.attacker.net', ['.example.com'])).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('normalisation', () => {
|
|
54
|
+
it('ignores case, because DNS does', () => {
|
|
55
|
+
expect(isHostAllowed('API.Example.COM', ['api.example.com'])).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ignores a trailing dot, which is the same name', () => {
|
|
59
|
+
// An allowlist that treats `example.com.` as different is bypassable
|
|
60
|
+
// by typing the host differently.
|
|
61
|
+
expect(isHostAllowed('example.com.', ['example.com'])).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('ignores surrounding whitespace in an entry', () => {
|
|
65
|
+
expect(isHostAllowed('example.com', [' example.com '])).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('an empty allowlist', () => {
|
|
70
|
+
it('denies everything', () => {
|
|
71
|
+
expect(isHostAllowed('example.com', [])).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is not satisfied by an empty entry', () => {
|
|
75
|
+
// `''` must not become a wildcard through some string coincidence.
|
|
76
|
+
expect(isHostAllowed('example.com', ['', ' '])).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('denies an empty host', () => {
|
|
80
|
+
expect(isHostAllowed('', ['example.com'])).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('splitting an authority', () => {
|
|
85
|
+
it('separates host from port', () => {
|
|
86
|
+
expect(splitAuthority('example.com:8443')).toEqual({ host: 'example.com', port: 8443 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('leaves a bare host alone', () => {
|
|
90
|
+
expect(splitAuthority('example.com')).toEqual({ host: 'example.com' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('handles a bracketed IPv6 literal', () => {
|
|
94
|
+
// A `split(':')` would shred this, and the allowlist would then be
|
|
95
|
+
// matching against a fragment.
|
|
96
|
+
expect(splitAuthority('[::1]:9000')).toEqual({ host: '::1', port: 9000 })
|
|
97
|
+
expect(splitAuthority('[2001:db8::1]')).toEqual({ host: '2001:db8::1' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('keeps the whole value when the port is not a number', () => {
|
|
101
|
+
expect(splitAuthority('example.com:notaport')).toEqual({ host: 'example.com:notaport' })
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createServer, request as nodeRequest } from 'node:http'
|
|
2
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { EgressProxy } from '../proxy.js'
|
|
6
|
+
import type { RunningEgressProxy } from '../proxy.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Until this existed, an egress policy could be declared and only two of
|
|
10
|
+
* its four shapes could be honoured anywhere: the container backend
|
|
11
|
+
* refused a host allowlist outright because it had no proxy to filter
|
|
12
|
+
* through. `deny-all` and `allow-all` were the whole spectrum — all or
|
|
13
|
+
* nothing.
|
|
14
|
+
*
|
|
15
|
+
* It also settles where credentials live. Any token the agent needed to
|
|
16
|
+
* reach an allowed host had to be INSIDE the sandbox, in the environment,
|
|
17
|
+
* readable by the untrusted code it is meant to be isolated from — via
|
|
18
|
+
* `/proc/self/environ`, or via a prompt injection that exfiltrates it over
|
|
19
|
+
* the very egress the policy permits.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A stand-in upstream that reports what it was sent. */
|
|
23
|
+
function upstream(): Promise<{ server: Server; port: number; seen: IncomingMessage[] }> {
|
|
24
|
+
const seen: IncomingMessage[] = []
|
|
25
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
26
|
+
seen.push(req)
|
|
27
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
28
|
+
res.end('upstream ok')
|
|
29
|
+
})
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
server.listen(0, '127.0.0.1', () => {
|
|
32
|
+
const address = server.address()
|
|
33
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
34
|
+
resolve({ server, port, seen })
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Issue a request THROUGH the proxy, the way a proxied client does: the
|
|
41
|
+
* absolute URL goes in the request line.
|
|
42
|
+
*
|
|
43
|
+
* `fetch` cannot express this — it owns the `Host` header and refuses an
|
|
44
|
+
* override, so a request built with it names the proxy as its own target.
|
|
45
|
+
* The first version of this helper did exactly that, and the proxy
|
|
46
|
+
* forwarded to itself and hung. That hang is now a 400, and this helper
|
|
47
|
+
* speaks the protocol properly.
|
|
48
|
+
*/
|
|
49
|
+
function viaProxy(
|
|
50
|
+
proxy: RunningEgressProxy,
|
|
51
|
+
target: string,
|
|
52
|
+
init: { headers?: Record<string, string> } = {},
|
|
53
|
+
): Promise<{ status: number; body: string }> {
|
|
54
|
+
const proxyUrl = new URL(proxy.url)
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const req = nodeRequest(
|
|
57
|
+
{
|
|
58
|
+
host: proxyUrl.hostname,
|
|
59
|
+
port: Number(proxyUrl.port),
|
|
60
|
+
method: 'GET',
|
|
61
|
+
// Absolute-URI form: what distinguishes a proxied request.
|
|
62
|
+
path: target,
|
|
63
|
+
headers: init.headers ?? {},
|
|
64
|
+
},
|
|
65
|
+
(res) => {
|
|
66
|
+
let body = ''
|
|
67
|
+
res.setEncoding('utf-8')
|
|
68
|
+
res.on('data', (chunk: string) => {
|
|
69
|
+
body += chunk
|
|
70
|
+
})
|
|
71
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }))
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
req.on('error', reject)
|
|
75
|
+
req.end()
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('the boundary', () => {
|
|
80
|
+
let proxy: RunningEgressProxy
|
|
81
|
+
let allowed: string[]
|
|
82
|
+
let denied: Array<{ host: string; reason: string }>
|
|
83
|
+
let target: Awaited<ReturnType<typeof upstream>>
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
target = await upstream()
|
|
87
|
+
allowed = ['127.0.0.1']
|
|
88
|
+
denied = []
|
|
89
|
+
proxy = await new EgressProxy({
|
|
90
|
+
allowedHosts: async () => allowed,
|
|
91
|
+
// The stand-in upstream speaks plain HTTP.
|
|
92
|
+
upgradeToHttps: false,
|
|
93
|
+
onDenied: (host, reason) => denied.push({ host, reason }),
|
|
94
|
+
}).listen()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
await proxy.close()
|
|
99
|
+
await new Promise<void>((resolve) => target.server.close(() => resolve()))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('lets an allowed host through', async () => {
|
|
103
|
+
const res = await viaProxy(proxy, `http://127.0.0.1:${target.port}/thing`)
|
|
104
|
+
expect(res.status).toBe(200)
|
|
105
|
+
expect(res.body).toBe('upstream ok')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('refuses a host that is not on the list', async () => {
|
|
109
|
+
allowed = ['example.com']
|
|
110
|
+
const res = await viaProxy(proxy, `http://127.0.0.1:${target.port}/thing`)
|
|
111
|
+
|
|
112
|
+
expect(res.status).toBe(403)
|
|
113
|
+
// Never reached the upstream at all — the refusal is the point, not
|
|
114
|
+
// the response code.
|
|
115
|
+
expect(target.seen).toHaveLength(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('names the host it refused', async () => {
|
|
119
|
+
allowed = []
|
|
120
|
+
const res = await viaProxy(proxy, `http://127.0.0.1:${target.port}/thing`)
|
|
121
|
+
|
|
122
|
+
// An agent that cannot tell "not permitted" from "network down"
|
|
123
|
+
// retries forever, and a human debugging it has nothing to go on.
|
|
124
|
+
expect(res.body).toContain('127.0.0.1')
|
|
125
|
+
expect(denied[0]?.host).toBe('127.0.0.1')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('denies when the policy cannot be read', async () => {
|
|
129
|
+
// An allowlist that fails open is not an allowlist.
|
|
130
|
+
const failing = await new EgressProxy({
|
|
131
|
+
allowedHosts: async () => {
|
|
132
|
+
throw new Error('policy service unavailable')
|
|
133
|
+
},
|
|
134
|
+
upgradeToHttps: false,
|
|
135
|
+
}).listen()
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const res = await viaProxy(failing, `http://127.0.0.1:${target.port}/thing`)
|
|
139
|
+
expect(res.status).toBe(403)
|
|
140
|
+
} finally {
|
|
141
|
+
await failing.close()
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('re-reads the policy per request, so a rotating allowlist is honoured', async () => {
|
|
146
|
+
allowed = []
|
|
147
|
+
expect((await viaProxy(proxy, `http://127.0.0.1:${target.port}/a`)).status).toBe(403)
|
|
148
|
+
|
|
149
|
+
allowed = ['127.0.0.1']
|
|
150
|
+
expect((await viaProxy(proxy, `http://127.0.0.1:${target.port}/b`)).status).toBe(200)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('can be narrowed on a live proxy', async () => {
|
|
154
|
+
// "Clone with a token, then drop to deny-all before running
|
|
155
|
+
// untrusted build scripts" was not expressible at all: the policy
|
|
156
|
+
// was frozen at provider construction.
|
|
157
|
+
expect((await viaProxy(proxy, `http://127.0.0.1:${target.port}/a`)).status).toBe(200)
|
|
158
|
+
|
|
159
|
+
proxy.setAllowedHosts(async () => [])
|
|
160
|
+
expect((await viaProxy(proxy, `http://127.0.0.1:${target.port}/b`)).status).toBe(403)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('brokering a credential', () => {
|
|
165
|
+
let proxy: RunningEgressProxy
|
|
166
|
+
let target: Awaited<ReturnType<typeof upstream>>
|
|
167
|
+
|
|
168
|
+
beforeEach(async () => {
|
|
169
|
+
target = await upstream()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
afterEach(async () => {
|
|
173
|
+
await proxy.close()
|
|
174
|
+
await new Promise<void>((resolve) => target.server.close(() => resolve()))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const withCredentials = async (host: string) => {
|
|
178
|
+
proxy = await new EgressProxy({
|
|
179
|
+
allowedHosts: async () => ['127.0.0.1'],
|
|
180
|
+
upgradeToHttps: false,
|
|
181
|
+
credentials: [{ host, header: 'authorization', value: 'Bearer real-secret' }],
|
|
182
|
+
}).listen()
|
|
183
|
+
return proxy
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it('stamps the real value on at the boundary', async () => {
|
|
187
|
+
const p = await withCredentials('127.0.0.1')
|
|
188
|
+
await viaProxy(p, `http://127.0.0.1:${target.port}/thing`)
|
|
189
|
+
|
|
190
|
+
// The token never entered the sandbox — a placeholder did, and the
|
|
191
|
+
// value was applied here.
|
|
192
|
+
expect(target.seen[0]?.headers.authorization).toBe('Bearer real-secret')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('sends it only to the host it belongs to', async () => {
|
|
196
|
+
// A credential attached to every request is a credential handed to
|
|
197
|
+
// whichever host the agent was talked into contacting.
|
|
198
|
+
const p = await withCredentials('other.example.com')
|
|
199
|
+
await viaProxy(p, `http://127.0.0.1:${target.port}/thing`)
|
|
200
|
+
|
|
201
|
+
expect(target.seen[0]?.headers.authorization).toBeUndefined()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not forward the hop-by-hop proxy header onward', async () => {
|
|
205
|
+
const p = await withCredentials('127.0.0.1')
|
|
206
|
+
await viaProxy(p, `http://127.0.0.1:${target.port}/thing`, {
|
|
207
|
+
headers: { 'proxy-authorization': 'Basic should-not-travel' },
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(target.seen[0]?.headers['proxy-authorization']).toBeUndefined()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host matching for an egress allowlist.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the proxy because this is the part that decides whether
|
|
5
|
+
* untrusted code reaches the network, and it has to be readable on its own
|
|
6
|
+
* and testable without opening a socket.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `host` is covered by `allowed`.
|
|
11
|
+
*
|
|
12
|
+
* Two forms, and only two:
|
|
13
|
+
*
|
|
14
|
+
* - `api.example.com` — that exact host.
|
|
15
|
+
* - `.example.com` — that domain and any subdomain of it.
|
|
16
|
+
*
|
|
17
|
+
* Substring matching is deliberately NOT one of them. `host.includes(entry)`
|
|
18
|
+
* is the obvious implementation and it is a hole: an allowlist entry of
|
|
19
|
+
* `example.com` would admit `example.com.attacker.net`, which is a domain
|
|
20
|
+
* the attacker owns. Suffix matching has the same hole without the leading
|
|
21
|
+
* dot — `notexample.com` ends with `example.com` — which is why the
|
|
22
|
+
* wildcard form requires it.
|
|
23
|
+
*
|
|
24
|
+
* Comparison is case-insensitive and ignores a trailing dot, because DNS
|
|
25
|
+
* treats `Example.COM.` and `example.com` as the same name and an allowlist
|
|
26
|
+
* that does not would be bypassable by typing the host differently.
|
|
27
|
+
*/
|
|
28
|
+
export function isHostAllowed(host: string, allowed: readonly string[]): boolean {
|
|
29
|
+
const target = normalizeHost(host)
|
|
30
|
+
if (target.length === 0) return false
|
|
31
|
+
|
|
32
|
+
for (const raw of allowed) {
|
|
33
|
+
const entry = normalizeHost(raw)
|
|
34
|
+
if (entry.length === 0) continue
|
|
35
|
+
|
|
36
|
+
if (entry.startsWith('.')) {
|
|
37
|
+
// `.example.com` covers `example.com` itself and any subdomain.
|
|
38
|
+
// Covering the apex matters: an allowlist author writing the
|
|
39
|
+
// wildcard form means the site, and a policy that admits
|
|
40
|
+
// `www.example.com` but not `example.com` reads as a bug.
|
|
41
|
+
const domain = entry.slice(1)
|
|
42
|
+
if (target === domain || target.endsWith(entry)) return true
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (target === entry) return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Lowercase, trailing-dot-free, whitespace-free. */
|
|
53
|
+
function normalizeHost(host: string): string {
|
|
54
|
+
return host.trim().toLowerCase().replace(/\.$/, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Split an authority into host and port.
|
|
59
|
+
*
|
|
60
|
+
* A CONNECT target and a `Host` header both carry `host:port`, and the
|
|
61
|
+
* allowlist is about the HOST — matching the whole authority would make an
|
|
62
|
+
* entry admit one port and silently refuse the same site on another.
|
|
63
|
+
* IPv6 literals are bracketed, which is why this is not a `split(':')`.
|
|
64
|
+
*/
|
|
65
|
+
export function splitAuthority(authority: string): { host: string; port?: number } {
|
|
66
|
+
const value = authority.trim()
|
|
67
|
+
|
|
68
|
+
if (value.startsWith('[')) {
|
|
69
|
+
const close = value.indexOf(']')
|
|
70
|
+
if (close < 0) return { host: value }
|
|
71
|
+
const host = value.slice(1, close)
|
|
72
|
+
const rest = value.slice(close + 1)
|
|
73
|
+
const port = rest.startsWith(':') ? Number(rest.slice(1)) : undefined
|
|
74
|
+
return port !== undefined && Number.isInteger(port) ? { host, port } : { host }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const colon = value.lastIndexOf(':')
|
|
78
|
+
if (colon < 0) return { host: value }
|
|
79
|
+
const port = Number(value.slice(colon + 1))
|
|
80
|
+
if (!Number.isInteger(port)) return { host: value }
|
|
81
|
+
return { host: value.slice(0, colon), port }
|
|
82
|
+
}
|