@namzu/sandbox 18.1.1 → 20.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 (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +35 -5
  3. package/dist/backends/docker/index.d.ts +46 -1
  4. package/dist/backends/docker/index.d.ts.map +1 -1
  5. package/dist/backends/docker/index.js +187 -51
  6. package/dist/backends/docker/index.js.map +1 -1
  7. package/dist/egress/index.d.ts +1 -0
  8. package/dist/egress/index.d.ts.map +1 -1
  9. package/dist/egress/index.js +1 -0
  10. package/dist/egress/index.js.map +1 -1
  11. package/dist/egress/profile-wiring.d.ts +40 -0
  12. package/dist/egress/profile-wiring.d.ts.map +1 -0
  13. package/dist/egress/profile-wiring.js +75 -0
  14. package/dist/egress/profile-wiring.js.map +1 -0
  15. package/dist/egress/profile.d.ts +153 -0
  16. package/dist/egress/profile.d.ts.map +1 -0
  17. package/dist/egress/profile.js +244 -0
  18. package/dist/egress/profile.js.map +1 -0
  19. package/dist/egress/proxy.d.ts +16 -0
  20. package/dist/egress/proxy.d.ts.map +1 -1
  21. package/dist/egress/proxy.js +37 -3
  22. package/dist/egress/proxy.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +12 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/seed/index.d.ts +165 -0
  28. package/dist/seed/index.d.ts.map +1 -0
  29. package/dist/seed/index.js +505 -0
  30. package/dist/seed/index.js.map +1 -0
  31. package/dist/testing/sandbox-conformance.d.ts +30 -1
  32. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  33. package/dist/testing/sandbox-conformance.js +18 -0
  34. package/dist/testing/sandbox-conformance.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/backends/docker/index.ts +246 -51
  37. package/src/egress/index.ts +1 -0
  38. package/src/egress/profile-wiring.ts +125 -0
  39. package/src/egress/profile.ts +380 -0
  40. package/src/egress/proxy.ts +53 -3
  41. package/src/index.ts +54 -5
  42. package/src/seed/index.ts +710 -0
  43. package/src/testing/sandbox-conformance.ts +52 -6
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What `createSandboxProvider` does with `egressProfile`: validate it, refuse
3
+ * it where the chosen backend cannot honour all of it, and turn it into the
4
+ * per-create {@link EgressPolicy} that backend already understands.
5
+ *
6
+ * Synchronous and free of I/O, so every refusal surfaces while the host wires
7
+ * its provider rather than on the first `create()`. The table of what each
8
+ * backend supports lives here once, in code, and is pinned by
9
+ * `src/egress/__tests__/egress-profile-wiring.test.ts`; there is deliberately no
10
+ * second copy of it to drift from this one.
11
+ */
12
+
13
+ import type { EgressPolicy, SandboxBackendConfig } from '../index.js'
14
+ import {
15
+ type SandboxEgressProfile,
16
+ SandboxEgressProfileError,
17
+ defineEgressProfile,
18
+ egressPolicyFromProfile,
19
+ egressProfileCoversEntry,
20
+ } from './profile.js'
21
+ import type { BrokeredCredential } from './proxy.js'
22
+
23
+ /** The resolved egress for a provider: the policy each create carries, and the profile behind it. */
24
+ export interface ProviderEgress {
25
+ readonly egress?: EgressPolicy
26
+ readonly profile?: SandboxEgressProfile
27
+ }
28
+
29
+ /** Which backend a config selects, in the words refusals use. */
30
+ function backendName(backend: SandboxBackendConfig): string {
31
+ if (backend.tier === 'container') {
32
+ if (backend.runtime === 'aci-standby-pool') return 'aci-standby-pool'
33
+ return backend.runtime ?? 'docker'
34
+ }
35
+ return backend.service === 'kubernetes' ? 'kubernetes' : 'firecracker'
36
+ }
37
+
38
+ /**
39
+ * Resolve `egressProfile` against `defaultEgress` and the backend.
40
+ *
41
+ * No profile returns `defaultEgress` untouched, so every existing config
42
+ * produces exactly the create options it did before.
43
+ */
44
+ export function resolveProviderEgress(config: {
45
+ readonly backend: SandboxBackendConfig
46
+ readonly defaultEgress?: EgressPolicy
47
+ readonly egressProfile?: SandboxEgressProfile
48
+ }): ProviderEgress {
49
+ if (config.egressProfile === undefined) {
50
+ return config.defaultEgress !== undefined ? { egress: config.defaultEgress } : {}
51
+ }
52
+ const profile = defineEgressProfile(config.egressProfile)
53
+ const backend = backendName(config.backend)
54
+ if (config.defaultEgress !== undefined) {
55
+ throw new SandboxEgressProfileError(
56
+ 'conflicting-policy',
57
+ 'defaultEgress',
58
+ 'is set beside egressProfile; a provider takes one egress policy, so set one of the two',
59
+ backend,
60
+ )
61
+ }
62
+ switch (backend) {
63
+ case 'kubernetes':
64
+ throw new SandboxEgressProfileError(
65
+ 'conflicting-policy',
66
+ 'egressProfile',
67
+ "is not read by the kubernetes backend: its egress is config-level and createKubernetesWorkspace takes the backend config directly, so a provider-level profile would never reach a workspace. Set backend.egress to kubernetesEgressFromProfile(profile, { engine: 'cilium' }) instead, which enforces the profile for task sandboxes and workspaces alike",
68
+ backend,
69
+ )
70
+ case 'aci-standby-pool':
71
+ throw new SandboxEgressProfileError(
72
+ 'unsupported',
73
+ 'egressProfile',
74
+ 'cannot be enforced: a standby-pool claim carries no per-sandbox egress policy, which is a property of the pooled container group profile',
75
+ backend,
76
+ )
77
+ case 'firecracker': {
78
+ const index = profile.hosts.findIndex((rule) => rule.ports !== undefined)
79
+ if (index !== -1) {
80
+ throw new SandboxEgressProfileError(
81
+ 'unsupported',
82
+ `hosts[${index}].ports`,
83
+ 'cannot be enforced: the orchestrator network policy names hosts and has no field for ports',
84
+ backend,
85
+ )
86
+ }
87
+ return { egress: egressPolicyFromProfile(profile), profile }
88
+ }
89
+ default: {
90
+ // docker and runsc: the egress proxy.
91
+ const container = config.backend as {
92
+ readonly brokeredCredentials?: readonly BrokeredCredential[]
93
+ }
94
+ // Ports are enforced by the proxy container on the port it dials; the
95
+ // backend checks the proxy image's label before starting it, since
96
+ // that needs the daemon.
97
+ assertBrokeredCredentialsFitProfile(profile, container.brokeredCredentials, backend)
98
+ return { egress: egressPolicyFromProfile(profile), profile }
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Refuse a brokered credential for a host the profile does not allow.
105
+ *
106
+ * Such a credential could never be stamped (the proxy refuses the host first),
107
+ * so accepting it would be a control declared and silently unused; and a
108
+ * credential aimed outside the allowlist is more often a typo in one of the
109
+ * two than a deliberate spare.
110
+ */
111
+ export function assertBrokeredCredentialsFitProfile(
112
+ profile: SandboxEgressProfile,
113
+ credentials: readonly BrokeredCredential[] | undefined,
114
+ backend: string,
115
+ ): void {
116
+ credentials?.forEach((credential, index) => {
117
+ if (egressProfileCoversEntry(profile, credential.host)) return
118
+ throw new SandboxEgressProfileError(
119
+ 'conflicting-policy',
120
+ `backend.brokeredCredentials[${index}].host`,
121
+ `${JSON.stringify(credential.host)} is not allowed by egress profile ${JSON.stringify(profile.name)}, so the proxy would refuse every request the credential is for`,
122
+ backend,
123
+ )
124
+ })
125
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Egress profiles: one named, validated host allowlist, with optional ports,
3
+ * that a host can hand to more than one backend and have it mean the same
4
+ * thing on each.
5
+ *
6
+ * The idea is the Gateway allowlist of google/ax (`HostRule { host, port }`),
7
+ * re-implemented against this package's own types. What is deliberately NOT
8
+ * taken from it: a `*:443` fallback when nothing is listed, continuing after a
9
+ * failed apply, credentials in the profile, and a controller that watches for
10
+ * changes. A profile with no hosts is no egress; there is no wildcard; a
11
+ * backend that cannot honour a part of a profile refuses it at construction,
12
+ * before any I/O, rather than applying the rest.
13
+ *
14
+ * A profile carries no credentials. Credential brokering exists only on the
15
+ * docker egress proxy, and `ContainerBackendConfig.brokeredCredentials` stays
16
+ * where it is on purpose; under a profile, a credential for a host the profile
17
+ * does not allow is refused (see `assertBrokeredCredentialsFitProfile`).
18
+ */
19
+
20
+ import type {
21
+ KubernetesCiliumEgressNarrowing,
22
+ KubernetesEgressConfig,
23
+ KubernetesEgressEngine,
24
+ KubernetesEgressVerification,
25
+ } from '../backends/kubernetes/egress-policy.js'
26
+ import type { EgressPolicy } from '../index.js'
27
+ import { isHostAllowed } from './allowlist.js'
28
+
29
+ /** One allowlist entry of a {@link SandboxEgressProfile}. */
30
+ export interface SandboxEgressHostRule {
31
+ /**
32
+ * `api.example.com` for that host, `.example.com` for that domain and
33
+ * every subdomain of it: the grammar `isHostAllowed` implements and
34
+ * `SandboxNetworkPolicy.allowedHosts` states. No `*`, no scheme, no path,
35
+ * no port suffix, no IP address.
36
+ */
37
+ readonly host: string
38
+ /**
39
+ * TCP ports this rule allows, each 1-65535. Absent means every port.
40
+ *
41
+ * When several rules match one host (`api.example.com` and `.example.com`
42
+ * both match `api.example.com`), the host may use the UNION of their ports,
43
+ * and a matching rule with no `ports` makes every port allowed. That is
44
+ * what Cilium does with the rules that select a pod, and the docker egress
45
+ * proxy computes the same union, so a profile means one thing on both.
46
+ */
47
+ readonly ports?: readonly number[]
48
+ }
49
+
50
+ /** A named egress allowlist. See the module doc. */
51
+ export interface SandboxEgressProfile {
52
+ /** A DNS-1123 label: lowercase letters, digits and `-`, at most 63 characters. */
53
+ readonly name: string
54
+ /** The allowed hosts. An empty list means no egress at all. */
55
+ readonly hosts: readonly SandboxEgressHostRule[]
56
+ }
57
+
58
+ /** Why a profile was refused. */
59
+ export type SandboxEgressProfileErrorCode =
60
+ | 'invalid-name'
61
+ | 'invalid-host'
62
+ | 'invalid-port'
63
+ | 'duplicate-host'
64
+ | 'conflicting-policy'
65
+ | 'unsupported'
66
+
67
+ /**
68
+ * A profile that is malformed, or that a backend cannot honour in full.
69
+ *
70
+ * `path` names the offending field relative to the profile (`hosts[1].ports`)
71
+ * or to the config it was combined with (`defaultEgress`); `backend` names the
72
+ * backend that refused, when the refusal is about a backend rather than about
73
+ * the profile itself.
74
+ */
75
+ export class SandboxEgressProfileError extends Error {
76
+ override readonly name = 'SandboxEgressProfileError'
77
+
78
+ constructor(
79
+ readonly code: SandboxEgressProfileErrorCode,
80
+ readonly path: string,
81
+ reason: string,
82
+ readonly backend?: string,
83
+ ) {
84
+ super(
85
+ `egress profile: ${path} ${reason}${backend !== undefined ? ` (backend: ${backend})` : ''}. Refusing rather than applying part of the profile.`,
86
+ )
87
+ }
88
+ }
89
+
90
+ const DNS_1123_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/
91
+ const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
92
+
93
+ /** Lowercase, no surrounding whitespace, no trailing dot. */
94
+ function normalizeHost(host: string): string {
95
+ return host.trim().toLowerCase().replace(/\.$/, '')
96
+ }
97
+
98
+ function hostProblem(host: string): string | undefined {
99
+ if (host === '') return 'is empty'
100
+ if (host.includes('*')) {
101
+ return "contains a wildcard; a domain and its subdomains are written with a leading dot ('.example.com'), and there is no allow-everything entry"
102
+ }
103
+ const bare = host.startsWith('.') ? host.slice(1) : host
104
+ if (bare === '') return 'names no domain after its leading dot'
105
+ if (!DNS_NAME.test(bare)) {
106
+ return 'is not a hostname (a scheme, a path, a port suffix and an IPv6 address all land here)'
107
+ }
108
+ if (bare.length > 253) return 'is longer than a DNS name may be'
109
+ if (host.startsWith('.') && !bare.includes('.')) {
110
+ return "names a whole top-level domain; a domain entry needs at least two labels, as in '.example.com'"
111
+ }
112
+ if (/^[0-9]+$/.test(bare.slice(bare.lastIndexOf('.') + 1))) {
113
+ return 'ends in an all-numeric label, so it is an address rather than a hostname'
114
+ }
115
+ return undefined
116
+ }
117
+
118
+ /**
119
+ * Validate and normalise a profile. The result is frozen, hosts are lowercased
120
+ * without a trailing dot, and ports are sorted.
121
+ *
122
+ * Refused with {@link SandboxEgressProfileError}: a name that is not a
123
+ * DNS-1123 label; a host outside the allowlist grammar (`*`, a scheme, a path,
124
+ * `host:port`, an IP literal); a port that is not an integer from 1 to 65535,
125
+ * a repeated port, or `ports: []` (write no `ports` for every port; an empty
126
+ * list would read as none and as all at once); and a host listed twice.
127
+ */
128
+ export function defineEgressProfile(input: SandboxEgressProfile): SandboxEgressProfile {
129
+ if (typeof input?.name !== 'string' || !DNS_1123_LABEL.test(input.name)) {
130
+ throw new SandboxEgressProfileError(
131
+ 'invalid-name',
132
+ 'name',
133
+ `${JSON.stringify(input?.name)} is not a DNS-1123 label (lowercase letters, digits and dashes, starting and ending alphanumeric, at most 63 characters)`,
134
+ )
135
+ }
136
+ if (!Array.isArray(input.hosts)) {
137
+ throw new SandboxEgressProfileError('invalid-host', 'hosts', 'is not a list')
138
+ }
139
+ const seen = new Set<string>()
140
+ const hosts = input.hosts.map((rule, index): SandboxEgressHostRule => {
141
+ const path = `hosts[${index}]`
142
+ if (typeof rule?.host !== 'string') {
143
+ throw new SandboxEgressProfileError('invalid-host', `${path}.host`, 'is not a string')
144
+ }
145
+ const host = normalizeHost(rule.host)
146
+ const problem = hostProblem(host)
147
+ if (problem !== undefined) {
148
+ throw new SandboxEgressProfileError(
149
+ 'invalid-host',
150
+ `${path}.host`,
151
+ `${JSON.stringify(rule.host)} ${problem}`,
152
+ )
153
+ }
154
+ if (seen.has(host)) {
155
+ throw new SandboxEgressProfileError(
156
+ 'duplicate-host',
157
+ `${path}.host`,
158
+ `${JSON.stringify(rule.host)} is listed more than once; merge the ports into one rule`,
159
+ )
160
+ }
161
+ seen.add(host)
162
+ if (rule.ports === undefined) return Object.freeze({ host })
163
+ if (!Array.isArray(rule.ports) || rule.ports.length === 0) {
164
+ throw new SandboxEgressProfileError(
165
+ 'invalid-port',
166
+ `${path}.ports`,
167
+ 'is empty; leave ports out to allow every port, or list at least one',
168
+ )
169
+ }
170
+ const ports = new Set<number>()
171
+ ;(rule.ports as readonly number[]).forEach((port: number, portIndex: number) => {
172
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
173
+ throw new SandboxEgressProfileError(
174
+ 'invalid-port',
175
+ `${path}.ports[${portIndex}]`,
176
+ `${JSON.stringify(port)} is not a TCP port (an integer from 1 to 65535)`,
177
+ )
178
+ }
179
+ if (ports.has(port)) {
180
+ throw new SandboxEgressProfileError(
181
+ 'invalid-port',
182
+ `${path}.ports[${portIndex}]`,
183
+ `${port} is listed more than once`,
184
+ )
185
+ }
186
+ ports.add(port)
187
+ })
188
+ return Object.freeze({ host, ports: Object.freeze([...ports].sort((a, b) => a - b)) })
189
+ })
190
+ return Object.freeze({ name: input.name, hosts: Object.freeze(hosts) })
191
+ }
192
+
193
+ /**
194
+ * The ports `host` may use under `profile`, or `'any'`.
195
+ *
196
+ * The union over every rule that matches the host; a matching rule with no
197
+ * `ports` makes it `'any'`. No matching rule is the empty list.
198
+ */
199
+ export function egressProfilePortsFor(
200
+ profile: SandboxEgressProfile,
201
+ host: string,
202
+ ): readonly number[] | 'any' {
203
+ return portsForRules(profile.hosts, host)
204
+ }
205
+
206
+ function portsForRules(
207
+ rules: readonly SandboxEgressHostRule[],
208
+ host: string,
209
+ ): readonly number[] | 'any' {
210
+ const ports = new Set<number>()
211
+ for (const rule of rules) {
212
+ if (!isHostAllowed(host, [rule.host])) continue
213
+ if (rule.ports === undefined) return 'any'
214
+ for (const port of rule.ports) ports.add(port)
215
+ }
216
+ return [...ports].sort((a, b) => a - b)
217
+ }
218
+
219
+ /**
220
+ * Whether `profile` lets `host` be reached on `port`: some matching rule
221
+ * either lists the port or lists no ports at all. This is the single
222
+ * implementation of the union rule; the docker proxy and the tests share it.
223
+ */
224
+ export function egressProfileAllowsPort(
225
+ profile: SandboxEgressProfile,
226
+ host: string,
227
+ port: number,
228
+ ): boolean {
229
+ const ports = egressProfilePortsFor(profile, host)
230
+ return ports === 'any' || ports.includes(port)
231
+ }
232
+
233
+ /**
234
+ * An `EgressProxyOptions.allowedPorts` function for a list of profile rules,
235
+ * with the same union rule as {@link egressProfileAllowsPort}. This is how the
236
+ * egress proxy container turns the rules it is handed into a port check, so
237
+ * the proxy and the profile cannot disagree about what a rule means.
238
+ */
239
+ export function egressPortsForRules(
240
+ rules: readonly SandboxEgressHostRule[],
241
+ ): (host: string) => readonly number[] | 'any' {
242
+ return (host) => portsForRules(rules, host)
243
+ }
244
+
245
+ /** Whether any rule of the profile carries `ports`. */
246
+ export function egressProfileHasPorts(profile: SandboxEgressProfile): boolean {
247
+ return profile.hosts.some((rule) => rule.ports !== undefined)
248
+ }
249
+
250
+ /**
251
+ * Whether an allowlist ENTRY (not a host) stays inside the profile.
252
+ *
253
+ * A plain host must be matched by a rule. A `.domain` entry covers a domain
254
+ * and all of its subdomains, so it needs a `.domain` rule for that domain or a
255
+ * parent of it: `api.example.com` in the profile does not cover
256
+ * `.api.example.com`, whose subdomains it never listed.
257
+ */
258
+ export function egressProfileCoversEntry(profile: SandboxEgressProfile, entry: string): boolean {
259
+ const normalized = normalizeHost(entry)
260
+ if (!normalized.startsWith('.'))
261
+ return isHostAllowed(
262
+ normalized,
263
+ profile.hosts.map((r) => r.host),
264
+ )
265
+ const domain = normalized.slice(1)
266
+ return profile.hosts.some(
267
+ (rule) =>
268
+ rule.host.startsWith('.') && (domain === rule.host.slice(1) || domain.endsWith(rule.host)),
269
+ )
270
+ }
271
+
272
+ /**
273
+ * The shared {@link EgressPolicy} a profile becomes on a backend that takes
274
+ * one per create: `deny-all` for no hosts, otherwise a `static` allowlist of
275
+ * the hosts as normalised. Ports are not in `EgressPolicy`; a backend that
276
+ * enforces them reads them from the profile.
277
+ */
278
+ export function egressPolicyFromProfile(profile: SandboxEgressProfile): EgressPolicy {
279
+ if (profile.hosts.length === 0) return { kind: 'deny-all' }
280
+ return { kind: 'static', allowedHosts: profile.hosts.map((rule) => rule.host) }
281
+ }
282
+
283
+ /** How {@link kubernetesEgressFromProfile} should have the profile enforced. */
284
+ export interface KubernetesEgressProfileEnforcement {
285
+ /** Required to be `'cilium'` when any rule carries `ports`, and for any host at all. */
286
+ readonly engine?: KubernetesEgressEngine
287
+ readonly verify?: KubernetesEgressVerification
288
+ readonly networkPolicyName?: string
289
+ /**
290
+ * Write the profile's name as the pod's egress-profile label. Default
291
+ * `false`, and the default output is then exactly what a hand-written
292
+ * config with the same policy would be.
293
+ *
294
+ * Turning it on changes things an operator has to act on: the default
295
+ * policy object becomes `${template}-${profile}-egress`, a stock controller
296
+ * refuses the claim until `allowed-label-domains` admits the key, and an
297
+ * existing workspace without the label no longer adopts. See
298
+ * `KubernetesEgressConfig.profile`.
299
+ */
300
+ readonly profileLabel?: boolean
301
+ /** Only with `profileLabel: true`. See `KubernetesEgressConfig.profileLabelKey`. */
302
+ readonly profileLabelKey?: string
303
+ /** Only with at least one host. See `KubernetesCiliumEgressNarrowing.dnsNames`. */
304
+ readonly dnsNames?: KubernetesCiliumEgressNarrowing['dnsNames']
305
+ }
306
+
307
+ /**
308
+ * Translate a profile into the existing {@link KubernetesEgressConfig}, for
309
+ * `KubernetesBackendConfig.egress`.
310
+ *
311
+ * Pure, and the one path for kubernetes: `createSandboxProvider` refuses
312
+ * `egressProfile` on a kubernetes backend and points here instead, because
313
+ * `createKubernetesWorkspace` takes the backend config directly and a
314
+ * provider-level profile would never reach a workspace. Put the result in
315
+ * `backend.egress` and task sandboxes and workspaces are enforced alike.
316
+ *
317
+ * No hosts is `deny-all`; otherwise a `static` allowlist. Ports become
318
+ * `ciliumNarrowing.hostPorts`, keyed by the host as written, and need
319
+ * `engine: 'cilium'`. A core engine with hosts is refused later, at wiring, by
320
+ * the backend's own `KubernetesUnenforceableEgressPolicyError`.
321
+ */
322
+ export function kubernetesEgressFromProfile(
323
+ profile: SandboxEgressProfile,
324
+ enforcement: KubernetesEgressProfileEnforcement = {},
325
+ ): KubernetesEgressConfig {
326
+ const defined = defineEgressProfile(profile)
327
+ const hostPorts: Record<string, readonly number[]> = {}
328
+ defined.hosts.forEach((rule, index) => {
329
+ if (rule.ports === undefined) return
330
+ if (enforcement.engine !== 'cilium') {
331
+ throw new SandboxEgressProfileError(
332
+ 'unsupported',
333
+ `hosts[${index}].ports`,
334
+ "needs engine: 'cilium'; core NetworkPolicy cannot restrict a hostname's ports",
335
+ 'kubernetes',
336
+ )
337
+ }
338
+ hostPorts[rule.host] = rule.ports
339
+ })
340
+ if (defined.hosts.length === 0 && enforcement.dnsNames !== undefined) {
341
+ throw new SandboxEgressProfileError(
342
+ 'unsupported',
343
+ 'enforcement.dnsNames',
344
+ 'narrows the DNS rule of a hostname allowlist, and a profile with no hosts is deny-all',
345
+ 'kubernetes',
346
+ )
347
+ }
348
+ if (enforcement.profileLabel !== true && enforcement.profileLabelKey !== undefined) {
349
+ throw new SandboxEgressProfileError(
350
+ 'conflicting-policy',
351
+ 'enforcement.profileLabelKey',
352
+ 'is set without profileLabel: true, so no label would be written under it',
353
+ 'kubernetes',
354
+ )
355
+ }
356
+ const narrowing: KubernetesCiliumEgressNarrowing = {
357
+ ...(Object.keys(hostPorts).length > 0 ? { hostPorts } : {}),
358
+ ...(enforcement.dnsNames !== undefined ? { dnsNames: enforcement.dnsNames } : {}),
359
+ }
360
+ return {
361
+ policy:
362
+ defined.hosts.length === 0
363
+ ? { kind: 'deny-all' }
364
+ : { kind: 'static', allowedHosts: defined.hosts.map((rule) => rule.host) },
365
+ ...(enforcement.networkPolicyName !== undefined
366
+ ? { networkPolicyName: enforcement.networkPolicyName }
367
+ : {}),
368
+ ...(enforcement.engine !== undefined ? { engine: enforcement.engine } : {}),
369
+ ...(enforcement.verify !== undefined ? { verify: enforcement.verify } : {}),
370
+ ...(Object.keys(narrowing).length > 0 ? { ciliumNarrowing: narrowing } : {}),
371
+ ...(enforcement.profileLabel === true
372
+ ? {
373
+ profile: defined.name,
374
+ ...(enforcement.profileLabelKey !== undefined
375
+ ? { profileLabelKey: enforcement.profileLabelKey }
376
+ : {}),
377
+ }
378
+ : {}),
379
+ }
380
+ }
@@ -102,6 +102,19 @@ export interface EgressProxyOptions {
102
102
  readonly selfNames?: readonly string[]
103
103
  /** Injected in tests. Defaults to the platform resolver. */
104
104
  readonly resolveAddresses?: ScreeningLookupOptions['resolve']
105
+ /**
106
+ * The TCP ports a host may be dialled on, or `'any'`. Absent means every
107
+ * port, which is the behaviour before this option existed.
108
+ *
109
+ * Checked against the port the socket is about to open, not the port the
110
+ * request names: a plain-HTTP request to `http://host/` is dialled on 443
111
+ * when `upgradeToHttps` is on, and a `CONNECT host` with no port on
112
+ * 443. The check sits beside the allowlist check, before any credential is
113
+ * looked up, and a denial is named. A function that throws denies.
114
+ * `egressPortsForRules` builds one from an egress profile's rules with the
115
+ * profile's union rule.
116
+ */
117
+ readonly allowedPorts?: (host: string) => readonly number[] | 'any'
105
118
  }
106
119
 
107
120
  export interface RunningEgressProxy {
@@ -142,6 +155,7 @@ export class EgressProxy {
142
155
  private readonly inwardAllowed: readonly string[]
143
156
  private readonly bindHost: string
144
157
  private readonly selfNames: readonly string[]
158
+ private readonly allowedPorts: ((host: string) => readonly number[] | 'any') | undefined
145
159
 
146
160
  constructor(options: EgressProxyOptions) {
147
161
  this.resolveAllowed = options.allowedHosts
@@ -151,6 +165,7 @@ export class EgressProxy {
151
165
  this.inwardAllowed = options.allowInwardFor ?? []
152
166
  this.bindHost = options.bindHost ?? LOOPBACK_BIND_HOST
153
167
  this.selfNames = options.selfNames ?? []
168
+ this.allowedPorts = options.allowedPorts
154
169
  this.lookup = createScreeningLookup(
155
170
  {
156
171
  ...(options.allowInwardFor ? { allowInwardFor: options.allowInwardFor } : {}),
@@ -226,6 +241,19 @@ export class EgressProxy {
226
241
  }
227
242
  }
228
243
 
244
+ /** Whether `port` is one `host` may be dialled on. See {@link EgressProxyOptions.allowedPorts}. */
245
+ private portAllowed(host: string, port: number): boolean {
246
+ if (this.allowedPorts === undefined) return true
247
+ try {
248
+ const ports = this.allowedPorts(host)
249
+ return ports === 'any' || ports.includes(port)
250
+ } catch {
251
+ // A port table that could not be read is a policy that could not be
252
+ // read, and is denied for the reason `allowed` denies one.
253
+ return false
254
+ }
255
+ }
256
+
229
257
  private deny(host: string, reason: string): void {
230
258
  this.onDenied?.(host, reason)
231
259
  }
@@ -269,6 +297,19 @@ export class EgressProxy {
269
297
  return
270
298
  }
271
299
 
300
+ // The port the socket will open, which is not always the port in the
301
+ // request: `http://host/` is dialled on 443 when the request is upgraded.
302
+ // Checked here, beside the allowlist and before the credential lookup,
303
+ // so a refused port never meets a token.
304
+ const secure = this.upgradeToHttps || target.protocol === 'https:'
305
+ const dialPort = target.port ?? (secure ? 443 : 80)
306
+ if (!this.portAllowed(target.host, dialPort)) {
307
+ this.deny(target.host, `port ${dialPort} is not allowed`)
308
+ res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
309
+ res.end(`Egress denied: ${target.host}:${dialPort} is not an allowed port.\n`)
310
+ return
311
+ }
312
+
272
313
  const literal = this.literalDenial(target.host)
273
314
  if (literal) {
274
315
  this.deny(target.host, `is a ${literal} address`)
@@ -295,13 +336,12 @@ export class EgressProxy {
295
336
  headers[credential.header.toLowerCase()] = credential.value
296
337
  }
297
338
 
298
- const secure = this.upgradeToHttps || target.protocol === 'https:'
299
339
  const send = secure ? httpsRequest : httpRequest
300
340
  const upstream = send(
301
341
  {
302
342
  protocol: secure ? 'https:' : 'http:',
303
343
  host: target.host,
304
- port: target.port ?? (secure ? 443 : 80),
344
+ port: dialPort,
305
345
  method: req.method,
306
346
  path: target.path,
307
347
  headers,
@@ -370,6 +410,16 @@ export class EgressProxy {
370
410
  return
371
411
  }
372
412
 
413
+ const dialPort = port ?? 443
414
+ if (!this.portAllowed(host, dialPort)) {
415
+ this.deny(host, `port ${dialPort} is not allowed`)
416
+ socket.write(
417
+ `HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host}:${dialPort} is not an allowed port.\n`,
418
+ )
419
+ socket.end()
420
+ return
421
+ }
422
+
373
423
  const literal = this.literalDenial(host)
374
424
  if (literal) {
375
425
  this.deny(host, `is a ${literal} address`)
@@ -384,7 +434,7 @@ export class EgressProxy {
384
434
  // brokered credential, so the loss here is reach rather than a token —
385
435
  // but an allowlisted name pointing inward still turns this proxy into
386
436
  // a route to the host's own network, which is what a sandbox is for.
387
- const upstream = netConnect({ port: port ?? 443, host, lookup: this.lookup }, () => {
437
+ const upstream = netConnect({ port: dialPort, host, lookup: this.lookup }, () => {
388
438
  socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
389
439
  if (head.length > 0) upstream.write(head)
390
440
  upstream.pipe(socket)