@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.
- package/CHANGELOG.md +93 -0
- package/README.md +35 -5
- package/dist/backends/docker/index.d.ts +46 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +187 -51
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/egress/index.d.ts +1 -0
- package/dist/egress/index.d.ts.map +1 -1
- package/dist/egress/index.js +1 -0
- package/dist/egress/index.js.map +1 -1
- package/dist/egress/profile-wiring.d.ts +40 -0
- package/dist/egress/profile-wiring.d.ts.map +1 -0
- package/dist/egress/profile-wiring.js +75 -0
- package/dist/egress/profile-wiring.js.map +1 -0
- package/dist/egress/profile.d.ts +153 -0
- package/dist/egress/profile.d.ts.map +1 -0
- package/dist/egress/profile.js +244 -0
- package/dist/egress/profile.js.map +1 -0
- package/dist/egress/proxy.d.ts +16 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +37 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/seed/index.d.ts +165 -0
- package/dist/seed/index.d.ts.map +1 -0
- package/dist/seed/index.js +505 -0
- package/dist/seed/index.js.map +1 -0
- package/dist/testing/sandbox-conformance.d.ts +30 -1
- package/dist/testing/sandbox-conformance.d.ts.map +1 -1
- package/dist/testing/sandbox-conformance.js +18 -0
- package/dist/testing/sandbox-conformance.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/docker/index.ts +246 -51
- package/src/egress/index.ts +1 -0
- package/src/egress/profile-wiring.ts +125 -0
- package/src/egress/profile.ts +380 -0
- package/src/egress/proxy.ts +53 -3
- package/src/index.ts +54 -5
- package/src/seed/index.ts +710 -0
- 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
|
+
}
|
package/src/egress/proxy.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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)
|