@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +277 -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 +144 -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 +70 -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 +210 -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 +76 -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 @@
1
+ {"version":3,"file":"allowlist.d.ts","sourceRoot":"","sources":["../../src/egress/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAsB/E;AAOD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAiBjF"}
@@ -0,0 +1,81 @@
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
+ * Whether `host` is covered by `allowed`.
10
+ *
11
+ * Two forms, and only two:
12
+ *
13
+ * - `api.example.com` — that exact host.
14
+ * - `.example.com` — that domain and any subdomain of it.
15
+ *
16
+ * Substring matching is deliberately NOT one of them. `host.includes(entry)`
17
+ * is the obvious implementation and it is a hole: an allowlist entry of
18
+ * `example.com` would admit `example.com.attacker.net`, which is a domain
19
+ * the attacker owns. Suffix matching has the same hole without the leading
20
+ * dot — `notexample.com` ends with `example.com` — which is why the
21
+ * wildcard form requires it.
22
+ *
23
+ * Comparison is case-insensitive and ignores a trailing dot, because DNS
24
+ * treats `Example.COM.` and `example.com` as the same name and an allowlist
25
+ * that does not would be bypassable by typing the host differently.
26
+ */
27
+ export function isHostAllowed(host, allowed) {
28
+ const target = normalizeHost(host);
29
+ if (target.length === 0)
30
+ return false;
31
+ for (const raw of allowed) {
32
+ const entry = normalizeHost(raw);
33
+ if (entry.length === 0)
34
+ continue;
35
+ if (entry.startsWith('.')) {
36
+ // `.example.com` covers `example.com` itself and any subdomain.
37
+ // Covering the apex matters: an allowlist author writing the
38
+ // wildcard form means the site, and a policy that admits
39
+ // `www.example.com` but not `example.com` reads as a bug.
40
+ const domain = entry.slice(1);
41
+ if (target === domain || target.endsWith(entry))
42
+ return true;
43
+ continue;
44
+ }
45
+ if (target === entry)
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ /** Lowercase, trailing-dot-free, whitespace-free. */
51
+ function normalizeHost(host) {
52
+ return host.trim().toLowerCase().replace(/\.$/, '');
53
+ }
54
+ /**
55
+ * Split an authority into host and port.
56
+ *
57
+ * A CONNECT target and a `Host` header both carry `host:port`, and the
58
+ * allowlist is about the HOST — matching the whole authority would make an
59
+ * entry admit one port and silently refuse the same site on another.
60
+ * IPv6 literals are bracketed, which is why this is not a `split(':')`.
61
+ */
62
+ export function splitAuthority(authority) {
63
+ const value = authority.trim();
64
+ if (value.startsWith('[')) {
65
+ const close = value.indexOf(']');
66
+ if (close < 0)
67
+ return { host: value };
68
+ const host = value.slice(1, close);
69
+ const rest = value.slice(close + 1);
70
+ const port = rest.startsWith(':') ? Number(rest.slice(1)) : undefined;
71
+ return port !== undefined && Number.isInteger(port) ? { host, port } : { host };
72
+ }
73
+ const colon = value.lastIndexOf(':');
74
+ if (colon < 0)
75
+ return { host: value };
76
+ const port = Number(value.slice(colon + 1));
77
+ if (!Number.isInteger(port))
78
+ return { host: value };
79
+ return { host: value.slice(0, colon), port };
80
+ }
81
+ //# sourceMappingURL=allowlist.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../../src/egress/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,OAA0B;IACrE,MAAM,MAAM,GAAG,aAAa,CAAC,IAAI,CAAC,CAAA;IAClC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAErC,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,aAAa,CAAC,GAAG,CAAC,CAAA;QAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,SAAQ;QAEhC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3B,gEAAgE;YAChE,6DAA6D;YAC7D,yDAAyD;YACzD,0DAA0D;YAC1D,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;YAC7B,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAAE,OAAO,IAAI,CAAA;YAC5D,SAAQ;QACT,CAAC;QAED,IAAI,MAAM,KAAK,KAAK;YAAE,OAAO,IAAI,CAAA;IAClC,CAAC;IAED,OAAO,KAAK,CAAA;AACb,CAAC;AAED,qDAAqD;AACrD,SAAS,aAAa,CAAC,IAAY;IAClC,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AACpD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC/C,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,EAAE,CAAA;IAE9B,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAChC,IAAI,KAAK,GAAG,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;QACrC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;QAClC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAA;QACnC,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QACrE,OAAO,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAA;IAChF,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACpC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IACrC,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAA;IAC3C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IACnD,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;AAC7C,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { isHostAllowed, splitAuthority } from './allowlist.js';
2
+ export { EgressProxy } from './proxy.js';
3
+ export type { BrokeredCredential, EgressProxyOptions, RunningEgressProxy, } from './proxy.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,YAAY,EACX,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,GAClB,MAAM,YAAY,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { isHostAllowed, splitAuthority } from './allowlist.js';
2
+ export { EgressProxy } from './proxy.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The network boundary a sandbox's egress policy is enforced at.
3
+ *
4
+ * Until this existed, an egress policy could be *declared* and only two of
5
+ * its four shapes could be honoured anywhere: the container backend refuses
6
+ * a host allowlist outright because it has no proxy to filter through, and
7
+ * only the microVM backend forwards one. `deny-all` and `allow-all` are the
8
+ * whole spectrum a container-tier sandbox could express — all or nothing.
9
+ *
10
+ * It also settles where credentials live. Any token the agent needs in
11
+ * order to reach an allowed host used to have to be inside the sandbox, in
12
+ * the environment, readable by the untrusted code it is meant to be
13
+ * isolated from — via `/proc/self/environ`, or via a prompt injection that
14
+ * exfiltrates it over the very egress the policy permits. Here the token
15
+ * never enters the sandbox: a placeholder does, and the real value is
16
+ * stamped on at this boundary.
17
+ */
18
+ /** A credential the proxy stamps on, and the host it may be sent to. */
19
+ export interface BrokeredCredential {
20
+ /**
21
+ * Host this credential is for. Same matching rules as the allowlist,
22
+ * so `.example.com` covers subdomains.
23
+ *
24
+ * Scoped per host on purpose: a credential attached to every request
25
+ * is a credential handed to whichever host the agent was talked into
26
+ * contacting.
27
+ */
28
+ readonly host: string;
29
+ /** Header to set, e.g. `authorization`. */
30
+ readonly header: string;
31
+ /** The real value. Never leaves this process. */
32
+ readonly value: string;
33
+ }
34
+ export interface EgressProxyOptions {
35
+ /** Resolved at request time, so a rotating allowlist is honoured. */
36
+ readonly allowedHosts: () => Promise<readonly string[]>;
37
+ readonly credentials?: readonly BrokeredCredential[];
38
+ /**
39
+ * Rewrite a plain-HTTP request to HTTPS upstream.
40
+ *
41
+ * This is what makes brokering work at all. A credential cannot be
42
+ * injected into a CONNECT tunnel — the bytes are already encrypted by
43
+ * the time they reach the proxy, and reading them would mean
44
+ * terminating TLS with a CA the sandbox trusts, which is a far larger
45
+ * and far more dangerous thing to build. So the sandbox speaks plain
46
+ * HTTP to the proxy, and the proxy speaks HTTPS to the world.
47
+ *
48
+ * Default `true`. Turn it off only for a genuinely plaintext upstream.
49
+ */
50
+ readonly upgradeToHttps?: boolean;
51
+ readonly onDenied?: (host: string, reason: string) => void;
52
+ }
53
+ export interface RunningEgressProxy {
54
+ readonly port: number;
55
+ /** `http://127.0.0.1:<port>` — what a sandbox sets `HTTP_PROXY` to. */
56
+ readonly url: string;
57
+ /** Swap the allowlist on a live proxy. See `setNetworkPolicy`. */
58
+ setAllowedHosts(resolve: () => Promise<readonly string[]>): void;
59
+ close(): Promise<void>;
60
+ }
61
+ export declare class EgressProxy {
62
+ private resolveAllowed;
63
+ private readonly credentials;
64
+ private readonly upgradeToHttps;
65
+ private readonly onDenied;
66
+ /** See the loop guard in `listen`. */
67
+ private selfPort;
68
+ constructor(options: EgressProxyOptions);
69
+ listen(port?: number): Promise<RunningEgressProxy>;
70
+ private allowed;
71
+ private deny;
72
+ /** Plain HTTP. The only path where a credential can be stamped on. */
73
+ private handleRequest;
74
+ /**
75
+ * HTTPS, tunnelled.
76
+ *
77
+ * The allowlist is enforceable here because the CONNECT target names
78
+ * the host in clear text. Credential brokering is NOT: the tunnel is
79
+ * opaque, and injecting into it would mean terminating TLS with a CA
80
+ * the sandbox trusts — which would let this process read every byte the
81
+ * agent sends anywhere, a strictly larger risk than the one being
82
+ * mitigated. A workload that needs brokering speaks plain HTTP to the
83
+ * proxy and lets it upgrade upstream.
84
+ */
85
+ private handleConnect;
86
+ /** Whether a target names this proxy. See the loop guard in `listen`. */
87
+ private isSelf;
88
+ private credentialFor;
89
+ }
90
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/egress/proxy.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;GAgBG;AAEH,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IAClC;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2CAA2C;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,iDAAiD;IACjD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,kBAAkB;IAClC,qEAAqE;IACrE,QAAQ,CAAC,YAAY,EAAE,MAAM,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;IACvD,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAA;IACpD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAA;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAC1D;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,uEAAuE;IACvE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,eAAe,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,IAAI,CAAA;IAChE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACtB;AAID,qBAAa,WAAW;IACvB,OAAO,CAAC,cAAc,CAAkC;IACxD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsD;IAC/E,sCAAsC;IACtC,OAAO,CAAC,QAAQ,CAAoB;gBAExB,OAAO,EAAE,kBAAkB;IAOjC,MAAM,CAAC,IAAI,SAAI,GAAG,OAAO,CAAC,kBAAkB,CAAC;YAyCrC,OAAO;IAWrB,OAAO,CAAC,IAAI;IAIZ,sEAAsE;YACxD,aAAa;IA8D3B;;;;;;;;;;OAUG;YACW,aAAa;IA2B3B,yEAAyE;IACzE,OAAO,CAAC,MAAM;IAKd,OAAO,CAAC,aAAa;CAGrB"}
@@ -0,0 +1,194 @@
1
+ import { createServer, request as httpRequest } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { connect as netConnect } from 'node:net';
4
+ import { isHostAllowed, splitAuthority } from './allowlist.js';
5
+ const DENIED_STATUS = 403;
6
+ export class EgressProxy {
7
+ resolveAllowed;
8
+ credentials;
9
+ upgradeToHttps;
10
+ onDenied;
11
+ /** See the loop guard in `listen`. */
12
+ selfPort;
13
+ constructor(options) {
14
+ this.resolveAllowed = options.allowedHosts;
15
+ this.credentials = options.credentials ?? [];
16
+ this.upgradeToHttps = options.upgradeToHttps ?? true;
17
+ this.onDenied = options.onDenied;
18
+ }
19
+ async listen(port = 0) {
20
+ const server = createServer((req, res) => {
21
+ void this.handleRequest(req, res);
22
+ });
23
+ server.on('connect', (req, socket, head) => {
24
+ void this.handleConnect(req, socket, head);
25
+ });
26
+ await new Promise((resolve, reject) => {
27
+ server.once('error', reject);
28
+ // Loopback only. A proxy that holds real credentials and binds
29
+ // every interface is reachable by anything on the network, which
30
+ // is the opposite of what it exists for.
31
+ server.listen(port, '127.0.0.1', () => {
32
+ server.off('error', reject);
33
+ resolve();
34
+ });
35
+ });
36
+ const address = server.address();
37
+ const boundPort = typeof address === 'object' && address !== null ? address.port : port;
38
+ // Refuse to forward to ourselves. A client that sends its `Host` as
39
+ // the proxy's own address — which is what a library that rewrites
40
+ // `Host` on a proxied request produces — would otherwise make the
41
+ // proxy call itself forever, holding a socket per hop until the
42
+ // process runs out. Found by a test that hung rather than failed,
43
+ // which is the shape this failure takes in production too.
44
+ this.selfPort = boundPort;
45
+ return {
46
+ port: boundPort,
47
+ url: `http://127.0.0.1:${boundPort}`,
48
+ setAllowedHosts: (resolve) => {
49
+ this.resolveAllowed = resolve;
50
+ },
51
+ close: () => new Promise((resolve) => {
52
+ server.close(() => resolve());
53
+ }),
54
+ };
55
+ }
56
+ async allowed(host) {
57
+ try {
58
+ return isHostAllowed(host, await this.resolveAllowed());
59
+ }
60
+ catch {
61
+ // A resolver that throws is a policy that could not be read.
62
+ // Denying is the only safe reading: an allowlist that fails open
63
+ // is not an allowlist.
64
+ return false;
65
+ }
66
+ }
67
+ deny(host, reason) {
68
+ this.onDenied?.(host, reason);
69
+ }
70
+ /** Plain HTTP. The only path where a credential can be stamped on. */
71
+ async handleRequest(req, res) {
72
+ const target = parseTarget(req);
73
+ if (!target) {
74
+ res.writeHead(400, { 'content-type': 'text/plain' });
75
+ res.end('Malformed proxy request: no absolute URL and no Host header.\n');
76
+ return;
77
+ }
78
+ if (this.isSelf(target.host, target.port)) {
79
+ res.writeHead(400, { 'content-type': 'text/plain' });
80
+ res.end('Refusing to proxy a request addressed to the proxy itself; forwarding it would loop until the process ran out of sockets. Send an absolute URL in the request line, which is what a proxied client does.\n');
81
+ return;
82
+ }
83
+ if (!(await this.allowed(target.host))) {
84
+ this.deny(target.host, 'not on the egress allowlist');
85
+ res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' });
86
+ // Named, not a generic refusal: an agent that cannot tell "this
87
+ // host is not permitted" from "the network is down" will retry
88
+ // forever, and a human debugging it has nothing to go on.
89
+ res.end(`Egress denied: ${target.host} is not on this sandbox's allowlist.\n`);
90
+ return;
91
+ }
92
+ const headers = { ...req.headers };
93
+ // The proxy re-issues the request, so hop-by-hop headers about the
94
+ // hop that just ended must not be forwarded.
95
+ for (const hop of ['proxy-authorization', 'proxy-connection', 'connection', 'keep-alive']) {
96
+ delete headers[hop];
97
+ }
98
+ const credential = this.credentialFor(target.host);
99
+ if (credential) {
100
+ headers[credential.header.toLowerCase()] = credential.value;
101
+ }
102
+ const secure = this.upgradeToHttps || target.protocol === 'https:';
103
+ const send = secure ? httpsRequest : httpRequest;
104
+ const upstream = send({
105
+ protocol: secure ? 'https:' : 'http:',
106
+ host: target.host,
107
+ port: target.port ?? (secure ? 443 : 80),
108
+ method: req.method,
109
+ path: target.path,
110
+ headers,
111
+ }, (response) => {
112
+ res.writeHead(response.statusCode ?? 502, response.headers);
113
+ response.pipe(res);
114
+ });
115
+ upstream.on('error', (err) => {
116
+ if (!res.headersSent)
117
+ res.writeHead(502, { 'content-type': 'text/plain' });
118
+ res.end(`Upstream request failed: ${err.message}\n`);
119
+ });
120
+ req.pipe(upstream);
121
+ }
122
+ /**
123
+ * HTTPS, tunnelled.
124
+ *
125
+ * The allowlist is enforceable here because the CONNECT target names
126
+ * the host in clear text. Credential brokering is NOT: the tunnel is
127
+ * opaque, and injecting into it would mean terminating TLS with a CA
128
+ * the sandbox trusts — which would let this process read every byte the
129
+ * agent sends anywhere, a strictly larger risk than the one being
130
+ * mitigated. A workload that needs brokering speaks plain HTTP to the
131
+ * proxy and lets it upgrade upstream.
132
+ */
133
+ async handleConnect(req, socket, head) {
134
+ const { host, port } = splitAuthority(req.url ?? '');
135
+ if (!(await this.allowed(host))) {
136
+ this.deny(host, 'not on the egress allowlist');
137
+ socket.write(`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host} is not on this sandbox's allowlist.\n`);
138
+ socket.end();
139
+ return;
140
+ }
141
+ const upstream = netConnect(port ?? 443, host, () => {
142
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
143
+ if (head.length > 0)
144
+ upstream.write(head);
145
+ upstream.pipe(socket);
146
+ socket.pipe(upstream);
147
+ });
148
+ upstream.on('error', () => {
149
+ socket.end();
150
+ });
151
+ socket.on('error', () => {
152
+ upstream.destroy();
153
+ });
154
+ }
155
+ /** Whether a target names this proxy. See the loop guard in `listen`. */
156
+ isSelf(host, port) {
157
+ if (port === undefined || port !== this.selfPort)
158
+ return false;
159
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
160
+ }
161
+ credentialFor(host) {
162
+ return this.credentials.find((c) => isHostAllowed(host, [c.host]));
163
+ }
164
+ }
165
+ /**
166
+ * Read the target from a proxied request.
167
+ *
168
+ * A proxy receives an absolute URL in the request line; a client that
169
+ * forgot it still sends `Host`, and honouring that is what makes a plain
170
+ * `fetch` through `HTTP_PROXY` work.
171
+ */
172
+ function parseTarget(req) {
173
+ const raw = req.url ?? '';
174
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
175
+ const url = new URL(raw);
176
+ return {
177
+ host: url.hostname,
178
+ ...(url.port ? { port: Number(url.port) } : {}),
179
+ path: `${url.pathname}${url.search}`,
180
+ protocol: url.protocol,
181
+ };
182
+ }
183
+ const hostHeader = req.headers.host;
184
+ if (!hostHeader)
185
+ return null;
186
+ const { host, port } = splitAuthority(hostHeader);
187
+ return {
188
+ host,
189
+ ...(port !== undefined ? { port } : {}),
190
+ path: raw || '/',
191
+ protocol: 'http:',
192
+ };
193
+ }
194
+ //# sourceMappingURL=proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/egress/proxy.ts"],"names":[],"mappings":"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"}