@namzu/sandbox 1.1.0 → 2.0.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/README.md +205 -105
  3. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +2 -0
  4. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +1 -0
  5. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +61 -0
  6. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +1 -0
  7. package/dist/backends/aci-standby-pool/index.d.ts +2 -1
  8. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  9. package/dist/backends/aci-standby-pool/index.js +36 -1
  10. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  11. package/dist/backends/docker/__tests__/hardening.test.d.ts +2 -0
  12. package/dist/backends/docker/__tests__/hardening.test.d.ts.map +1 -0
  13. package/dist/backends/docker/__tests__/hardening.test.js +32 -0
  14. package/dist/backends/docker/__tests__/hardening.test.js.map +1 -0
  15. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +1 -1
  16. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +1 -1
  17. package/dist/backends/docker/index.d.ts +47 -3
  18. package/dist/backends/docker/index.d.ts.map +1 -1
  19. package/dist/backends/docker/index.js +138 -5
  20. package/dist/backends/docker/index.js.map +1 -1
  21. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +16 -0
  22. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +1 -0
  23. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +37 -0
  24. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +1 -0
  25. package/dist/backends/firecracker/__tests__/backend.test.js +11 -3
  26. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -1
  27. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +10 -2
  28. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -1
  29. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +2 -0
  30. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +1 -0
  31. package/dist/backends/firecracker/__tests__/egress-policy.test.js +67 -0
  32. package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +1 -0
  33. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +21 -0
  34. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +1 -0
  35. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +30 -0
  36. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +1 -0
  37. package/dist/backends/firecracker/__tests__/protocol.test.js +7 -17
  38. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -1
  39. package/dist/backends/firecracker/__tests__/transport.test.js +11 -3
  40. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -1
  41. package/dist/backends/firecracker/index.d.ts +20 -1
  42. package/dist/backends/firecracker/index.d.ts.map +1 -1
  43. package/dist/backends/firecracker/index.js +60 -15
  44. package/dist/backends/firecracker/index.js.map +1 -1
  45. package/dist/egress/__tests__/allowlist.test.d.ts +2 -0
  46. package/dist/egress/__tests__/allowlist.test.d.ts.map +1 -0
  47. package/dist/egress/__tests__/allowlist.test.js +85 -0
  48. package/dist/egress/__tests__/allowlist.test.js.map +1 -0
  49. package/dist/egress/__tests__/proxy.test.d.ts +2 -0
  50. package/dist/egress/__tests__/proxy.test.d.ts.map +1 -0
  51. package/dist/egress/__tests__/proxy.test.js +177 -0
  52. package/dist/egress/__tests__/proxy.test.js.map +1 -0
  53. package/dist/egress/allowlist.d.ts +40 -0
  54. package/dist/egress/allowlist.d.ts.map +1 -0
  55. package/dist/egress/allowlist.js +81 -0
  56. package/dist/egress/allowlist.js.map +1 -0
  57. package/dist/egress/index.d.ts +4 -0
  58. package/dist/egress/index.d.ts.map +1 -0
  59. package/dist/egress/index.js +3 -0
  60. package/dist/egress/index.js.map +1 -0
  61. package/dist/egress/proxy.d.ts +90 -0
  62. package/dist/egress/proxy.d.ts.map +1 -0
  63. package/dist/egress/proxy.js +194 -0
  64. package/dist/egress/proxy.js.map +1 -0
  65. package/dist/index.d.ts +101 -190
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +62 -80
  68. package/dist/index.js.map +1 -1
  69. package/dist/index.test.js +18 -39
  70. package/dist/index.test.js.map +1 -1
  71. package/package.json +5 -4
  72. package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +69 -0
  73. package/src/backends/aci-standby-pool/index.ts +42 -1
  74. package/src/backends/docker/__tests__/hardening.test.ts +43 -0
  75. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +1 -1
  76. package/src/backends/docker/index.ts +204 -12
  77. package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +48 -0
  78. package/src/backends/firecracker/__tests__/backend.test.ts +76 -65
  79. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +10 -2
  80. package/src/backends/firecracker/__tests__/egress-policy.test.ts +91 -0
  81. package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +31 -0
  82. package/src/backends/firecracker/__tests__/protocol.test.ts +8 -23
  83. package/src/backends/firecracker/__tests__/transport.test.ts +11 -3
  84. package/src/backends/firecracker/index.ts +66 -13
  85. package/src/egress/__tests__/allowlist.test.ts +103 -0
  86. package/src/egress/__tests__/proxy.test.ts +212 -0
  87. package/src/egress/allowlist.ts +82 -0
  88. package/src/egress/index.ts +7 -0
  89. package/src/egress/proxy.ts +294 -0
  90. package/src/index.test.ts +19 -41
  91. 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('still throws for the legacy local-containerd self-hosted shape (no orchestrator)', () => {
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
- tier: 'microvm',
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 = join(workDir, 'agent.sock')
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.replace(/\/+$/, '')}${pathSuffix}`
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
- ...(resolveEgressAllowlist(options) !== undefined
318
- ? { egressAllowlist: resolveEgressAllowlist(options) }
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
@@ -463,17 +480,53 @@ async function spawnFirecrackerSandbox(
463
480
  // Helpers
464
481
  // ---------------------------------------------------------------------------
465
482
 
466
- function resolveEgressAllowlist(options: SandboxBackendOptions): readonly string[] | undefined {
483
+ /**
484
+ * Materialise an egress policy into the allowlist the orchestrator turns
485
+ * into firewall rules.
486
+ *
487
+ * Omitting the field means "no allowlist to apply", which the orchestrator
488
+ * reads as unrestricted. That makes omission the encoding for `allow-all`
489
+ * and ONLY for `allow-all`.
490
+ *
491
+ * `resolver` used to be omitted too, so two opposite intentions shared one
492
+ * encoding and the tenant-scoped allowlist the variant exists for was
493
+ * silently absent — with the callback that would have produced it never
494
+ * called anywhere in the repo. Whichever way the orchestrator reads an
495
+ * omitted field, one of the two variants was always mis-enforced, and the
496
+ * one that failed open was the one whose entire purpose is restriction.
497
+ *
498
+ * The switch is exhaustive on purpose: a new variant should fail to
499
+ * compile here rather than fall through to "unrestricted".
500
+ */
501
+ export async function resolveEgressAllowlist(
502
+ options: SandboxBackendOptions,
503
+ ): Promise<readonly string[] | undefined> {
467
504
  const egress = options.egress
468
505
  if (!egress) return undefined
469
- if (egress.kind === 'static') return egress.allowedHosts
470
- // `deny-all` → empty allowlist (explicit). `allow-all` / `resolver`
471
- // are resolved by the host before create when they apply; the
472
- // backend forwards only the static, already-resolved shape. A
473
- // resolver-shaped policy is the Vandal lifecycle's job to resolve
474
- // upstream and pass as `static`.
475
- if (egress.kind === 'deny-all') return []
476
- return undefined
506
+
507
+ switch (egress.kind) {
508
+ case 'allow-all':
509
+ // The one intent omission is allowed to mean.
510
+ return undefined
511
+ case 'deny-all':
512
+ // Explicitly empty, not absent.
513
+ return []
514
+ case 'static':
515
+ return egress.allowedHosts
516
+ case 'resolver': {
517
+ // The callback exists to produce this list. Calling it is the
518
+ // whole feature; an empty result is a real deny-all and travels
519
+ // as one rather than collapsing back to omission.
520
+ const resolved = await egress.resolve()
521
+ return resolved
522
+ }
523
+ default: {
524
+ const exhaustive: never = egress
525
+ throw new Error(
526
+ `Unhandled egress policy kind: ${JSON.stringify(exhaustive)}. Refusing rather than defaulting to unrestricted network access.`,
527
+ )
528
+ }
529
+ }
477
530
  }
478
531
 
479
532
  /**
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ export { isHostAllowed, splitAuthority } from './allowlist.js'
2
+ export { EgressProxy } from './proxy.js'
3
+ export type {
4
+ BrokeredCredential,
5
+ EgressProxyOptions,
6
+ RunningEgressProxy,
7
+ } from './proxy.js'