@namzu/sandbox 2.0.3 → 4.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 +100 -0
- package/README.md +62 -1
- package/dist/backends/aci-standby-pool/index.d.ts +47 -5
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +37 -4
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts +26 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +26 -9
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/egress/address.d.ts +75 -0
- package/dist/egress/address.d.ts.map +1 -0
- package/dist/egress/address.js +289 -0
- package/dist/egress/address.js.map +1 -0
- package/dist/egress/index.d.ts +2 -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/proxy.d.ts +41 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +83 -2
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/aci-standby-pool/index.ts +63 -8
- package/src/backends/docker/index.ts +52 -11
- package/src/egress/address.ts +366 -0
- package/src/egress/index.ts +7 -0
- package/src/egress/proxy.ts +106 -2
- package/src/index.ts +18 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns'
|
|
2
|
+
import type { LookupAddress } from 'node:dns'
|
|
3
|
+
import { isIP } from 'node:net'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Address-level screening for egress.
|
|
7
|
+
*
|
|
8
|
+
* The allowlist answers "is this NAME permitted". That is a different
|
|
9
|
+
* question from "where does this name go", and only the second one decides
|
|
10
|
+
* what the socket actually reaches. A name on the allowlist whose DNS the
|
|
11
|
+
* attacker controls — or that simply has an inward-pointing record — resolves
|
|
12
|
+
* to the loopback interface, the private network the sandbox host sits on, or
|
|
13
|
+
* the link-local address cloud metadata services answer on.
|
|
14
|
+
*
|
|
15
|
+
* The proxy stamps a brokered credential on before the request goes out, so
|
|
16
|
+
* without this screen the credential-brokering design is the delivery
|
|
17
|
+
* mechanism: the token reaches whatever the name resolved to. That is the
|
|
18
|
+
* exact outcome `BrokeredCredential.host` exists to prevent, and it could not
|
|
19
|
+
* prevent it while the scope was a name rather than an address.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Blocked ranges, and the name of what each one protects. */
|
|
23
|
+
interface Range {
|
|
24
|
+
readonly reason: string
|
|
25
|
+
readonly matches: (octets: readonly number[]) => boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const V4_RANGES: readonly Range[] = [
|
|
29
|
+
{ reason: 'loopback', matches: (o) => o[0] === 127 },
|
|
30
|
+
{ reason: 'this-host', matches: (o) => o[0] === 0 },
|
|
31
|
+
{ reason: 'private', matches: (o) => o[0] === 10 },
|
|
32
|
+
{ reason: 'private', matches: (o) => o[0] === 172 && (o[1] ?? 0) >= 16 && (o[1] ?? 0) <= 31 },
|
|
33
|
+
{ reason: 'private', matches: (o) => o[0] === 192 && o[1] === 168 },
|
|
34
|
+
// 169.254.0.0/16. The metadata address every major host platform answers
|
|
35
|
+
// on lives here, which is why this range is the one that turns a name
|
|
36
|
+
// check into a credential leak.
|
|
37
|
+
{ reason: 'link-local', matches: (o) => o[0] === 169 && o[1] === 254 },
|
|
38
|
+
{
|
|
39
|
+
reason: 'shared-address-space',
|
|
40
|
+
matches: (o) => o[0] === 100 && (o[1] ?? 0) >= 64 && (o[1] ?? 0) <= 127,
|
|
41
|
+
},
|
|
42
|
+
{ reason: 'benchmarking', matches: (o) => o[0] === 198 && (o[1] === 18 || o[1] === 19) },
|
|
43
|
+
{ reason: 'multicast', matches: (o) => (o[0] ?? 0) >= 224 && (o[0] ?? 0) <= 239 },
|
|
44
|
+
{ reason: 'reserved', matches: (o) => (o[0] ?? 0) >= 240 },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
function parseV4(address: string): readonly number[] | null {
|
|
48
|
+
const parts = address.split('.')
|
|
49
|
+
if (parts.length !== 4) return null
|
|
50
|
+
const octets: number[] = []
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
if (!/^\d{1,3}$/.test(part)) return null
|
|
53
|
+
const n = Number(part)
|
|
54
|
+
if (n > 255) return null
|
|
55
|
+
octets.push(n)
|
|
56
|
+
}
|
|
57
|
+
return octets
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Expand an IPv6 literal into its eight 16-bit groups, or `null`.
|
|
62
|
+
*
|
|
63
|
+
* Written out rather than pattern-matched on the text, because a prefix
|
|
64
|
+
* matched as a STRING is a different question from a prefix matched as a
|
|
65
|
+
* NUMBER, and the two disagree exactly where it hurts. `/^f[cd]/` was the
|
|
66
|
+
* first spelling of the unique-local check here, and `fd::1` matches it —
|
|
67
|
+
* but `fd::1` is `00fd:0:…`, an ordinary global address, so that check
|
|
68
|
+
* deleted a slice of the internet. `fe8::1` did the same against
|
|
69
|
+
* `/^fe[89ab]/`. Both are the `>=`-where-`>`-was-meant mistake wearing a
|
|
70
|
+
* regex.
|
|
71
|
+
*
|
|
72
|
+
* It fails the other way too. `0:0:0:0:0:ffff:169.254.169.254` is the
|
|
73
|
+
* metadata address written long, and no `^::`-anchored pattern sees it.
|
|
74
|
+
*/
|
|
75
|
+
function parseV6(address: string): number[] | null {
|
|
76
|
+
// A zone id (`%eth0`) names an interface, not a different address.
|
|
77
|
+
const text = (address.toLowerCase().split('%')[0] ?? '').trim()
|
|
78
|
+
if (text.length === 0) return null
|
|
79
|
+
|
|
80
|
+
// A trailing dotted quad is the last two groups written in v4 notation.
|
|
81
|
+
let head = text
|
|
82
|
+
let tail: number[] = []
|
|
83
|
+
const lastColon = text.lastIndexOf(':')
|
|
84
|
+
const after = lastColon >= 0 ? text.slice(lastColon + 1) : ''
|
|
85
|
+
if (after.includes('.')) {
|
|
86
|
+
const quad = parseV4(after)
|
|
87
|
+
if (!quad) return null
|
|
88
|
+
const [a, b, c, d] = quad as [number, number, number, number]
|
|
89
|
+
tail = [(a << 8) | b, (c << 8) | d]
|
|
90
|
+
head = text.slice(0, lastColon + 1)
|
|
91
|
+
if (head.endsWith(':') && !head.endsWith('::')) head = head.slice(0, -1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const halves = head.split('::')
|
|
95
|
+
if (halves.length > 2) return null
|
|
96
|
+
|
|
97
|
+
const toGroups = (part: string): number[] | null => {
|
|
98
|
+
if (part.length === 0) return []
|
|
99
|
+
const out: number[] = []
|
|
100
|
+
for (const piece of part.split(':')) {
|
|
101
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece)) return null
|
|
102
|
+
out.push(Number.parseInt(piece, 16))
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (halves.length === 1) {
|
|
108
|
+
const only = toGroups(halves[0] ?? '')
|
|
109
|
+
if (!only) return null
|
|
110
|
+
const groups = [...only, ...tail]
|
|
111
|
+
return groups.length === 8 ? groups : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const left = toGroups(halves[0] ?? '')
|
|
115
|
+
const right = toGroups(halves[1] ?? '')
|
|
116
|
+
if (!left || !right) return null
|
|
117
|
+
const known = left.length + right.length + tail.length
|
|
118
|
+
if (known > 8) return null
|
|
119
|
+
return [...left, ...new Array<number>(8 - known).fill(0), ...right, ...tail]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether the first `count` groups are all zero. */
|
|
123
|
+
function zeroPrefix(groups: readonly number[], count: number): boolean {
|
|
124
|
+
return groups.slice(0, count).every((g) => g === 0)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Why this address must not be reached, or `null` if it may be.
|
|
129
|
+
*
|
|
130
|
+
* Returns a reason rather than a boolean because the reason is what a
|
|
131
|
+
* denied operator needs: "link-local" and "private" are different mistakes
|
|
132
|
+
* with different fixes, and a bare `false` sends them to read this file.
|
|
133
|
+
*/
|
|
134
|
+
export function blockedAddressReason(address: string): string | null {
|
|
135
|
+
const literal = unbracket(address)
|
|
136
|
+
const v4 = parseV4(literal)
|
|
137
|
+
if (v4) {
|
|
138
|
+
for (const range of V4_RANGES) {
|
|
139
|
+
if (range.matches(v4)) return range.reason
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const groups = parseV6(literal)
|
|
145
|
+
if (!groups) return null
|
|
146
|
+
|
|
147
|
+
// `::ffff:a.b.c.d` (v4-mapped) and the deprecated `::a.b.c.d`
|
|
148
|
+
// (v4-compatible) are both the v4 address wearing a v6 spelling, and both
|
|
149
|
+
// reach the v4 host. A screen that only understands dotted quads passes
|
|
150
|
+
// them, which is a documented way through this kind of filter rather than
|
|
151
|
+
// an oversight worth ignoring.
|
|
152
|
+
if (zeroPrefix(groups, 5) && groups[5] === 0xffff) {
|
|
153
|
+
return blockedAddressReason(v4FromGroups(groups))
|
|
154
|
+
}
|
|
155
|
+
if (zeroPrefix(groups, 6) && !(groups[6] === 0 && (groups[7] ?? 0) <= 1)) {
|
|
156
|
+
return blockedAddressReason(v4FromGroups(groups))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (zeroPrefix(groups, 7)) {
|
|
160
|
+
if (groups[7] === 1) return 'loopback'
|
|
161
|
+
if (groups[7] === 0) return 'unspecified'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const first = groups[0] ?? 0
|
|
165
|
+
// Masked, not prefix-matched: fc00::/7, fe80::/10, ff00::/8.
|
|
166
|
+
if ((first & 0xfe00) === 0xfc00) return 'unique-local'
|
|
167
|
+
if ((first & 0xffc0) === 0xfe80) return 'link-local'
|
|
168
|
+
if ((first & 0xff00) === 0xff00) return 'multicast'
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function v4FromGroups(groups: readonly number[]): string {
|
|
173
|
+
const high = groups[6] ?? 0
|
|
174
|
+
const low = groups[7] ?? 0
|
|
175
|
+
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An IPv6 literal arrives bracketed from one of the two paths.
|
|
180
|
+
*
|
|
181
|
+
* `new URL('http://[::1]/').hostname` is `[::1]`, brackets included, and
|
|
182
|
+
* `parseTarget` reads exactly that. The `Host`-header path hands the same
|
|
183
|
+
* address over bare, because `splitAuthority` strips the brackets itself —
|
|
184
|
+
* two spellings of one address, normalised here rather than at either call
|
|
185
|
+
* site.
|
|
186
|
+
*
|
|
187
|
+
* This is a layer, not a hole closed, and the difference was measured rather
|
|
188
|
+
* than assumed: Node does not read a bracketed string as an IP literal
|
|
189
|
+
* either, so without this the host falls through to `dns.lookup` and the
|
|
190
|
+
* screening resolver refuses it there. What this buys is that
|
|
191
|
+
* `blockedLiteralReason` stops answering `null` about an address it plainly
|
|
192
|
+
* recognises — a true-looking answer the next caller would build on.
|
|
193
|
+
*/
|
|
194
|
+
function unbracket(host: string): string {
|
|
195
|
+
const trimmed = host.trim()
|
|
196
|
+
return trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Screen a target that is already an address rather than a name.
|
|
201
|
+
*
|
|
202
|
+
* A `lookup` hook cannot cover this case and it is not obvious why: the
|
|
203
|
+
* socket layer skips resolution entirely when the host is a valid IP
|
|
204
|
+
* literal, so the screening resolver is never called. The whole existing
|
|
205
|
+
* egress suite passed with the resolver in place *because* its upstream is
|
|
206
|
+
* `127.0.0.1` — a literal, never resolved, never screened. A green suite
|
|
207
|
+
* was the evidence the hole was still open.
|
|
208
|
+
*
|
|
209
|
+
* So the two cases need two checks: names are screened inside the resolver
|
|
210
|
+
* the socket calls, literals are screened here before it dials.
|
|
211
|
+
*/
|
|
212
|
+
export function blockedLiteralReason(host: string): string | null {
|
|
213
|
+
// The `isIP` guard states the contract: only an address is screened here.
|
|
214
|
+
//
|
|
215
|
+
// It is defence in depth rather than the thing that saves a hostname, and
|
|
216
|
+
// that was measured — removing it kills no test, because `parseV6` refuses
|
|
217
|
+
// `fdsomething.example` and `ff-cdn.example` on its own. It earns its place
|
|
218
|
+
// against the version of this file that does not: the first screen written
|
|
219
|
+
// here matched `/^f[cd]/` against the raw text, and under that screen this
|
|
220
|
+
// line was the only thing standing between an ordinary CDN hostname and a
|
|
221
|
+
// refusal. Keeping it means a future loosening of the parser cannot quietly
|
|
222
|
+
// turn an address screen back into a name filter.
|
|
223
|
+
if (isIP(unbracket(host)) === 0) return null
|
|
224
|
+
return blockedAddressReason(host)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class EgressAddressDenied extends Error {
|
|
228
|
+
readonly host: string
|
|
229
|
+
readonly address: string
|
|
230
|
+
readonly reason: string
|
|
231
|
+
|
|
232
|
+
constructor(host: string, address: string, reason: string) {
|
|
233
|
+
super(
|
|
234
|
+
`Egress denied: ${host} resolves to ${address}, which is a ${reason} address. The host is on the allowlist; the address it resolves to is not reachable from a sandbox.`,
|
|
235
|
+
)
|
|
236
|
+
this.name = 'EgressAddressDenied'
|
|
237
|
+
this.host = host
|
|
238
|
+
this.address = address
|
|
239
|
+
this.reason = reason
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface ScreeningLookupOptions {
|
|
244
|
+
/**
|
|
245
|
+
* Hosts permitted to resolve inward anyway, matched by the allowlist's
|
|
246
|
+
* own rules so `.internal.example` covers subdomains.
|
|
247
|
+
*
|
|
248
|
+
* Per host, never a global switch. An operator who genuinely proxies to
|
|
249
|
+
* one service on a private network needs that service exempted, and
|
|
250
|
+
* turning the screen off entirely to get it would hand every other
|
|
251
|
+
* allowlisted name the same reach.
|
|
252
|
+
*/
|
|
253
|
+
readonly allowInwardFor?: readonly string[]
|
|
254
|
+
/** Injected in tests. Defaults to the platform resolver. */
|
|
255
|
+
readonly resolve?: AddressResolver
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The one shape this module asks a resolver for: every address, in the
|
|
260
|
+
* order the resolver returned them.
|
|
261
|
+
*
|
|
262
|
+
* Narrower than the platform signature on purpose. The screen has to see
|
|
263
|
+
* ALL the addresses to be worth anything, so a resolver that can be asked
|
|
264
|
+
* for one is a resolver this code could accidentally ask wrongly.
|
|
265
|
+
*/
|
|
266
|
+
export type AddressResolver = (
|
|
267
|
+
hostname: string,
|
|
268
|
+
options: { readonly all: true; readonly verbatim: true },
|
|
269
|
+
callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void,
|
|
270
|
+
) => void
|
|
271
|
+
|
|
272
|
+
const platformResolver: AddressResolver = (hostname, options, callback) => {
|
|
273
|
+
dnsLookup(hostname, options, callback)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
type LookupCallback = (
|
|
277
|
+
err: NodeJS.ErrnoException | null,
|
|
278
|
+
address: string | LookupAddress[],
|
|
279
|
+
family?: number,
|
|
280
|
+
) => void
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A `lookup` implementation that screens before the socket uses the answer.
|
|
284
|
+
*
|
|
285
|
+
* This is deliberately not "resolve, check, then connect to the address we
|
|
286
|
+
* checked". That shape leaves the socket free to resolve again, and the
|
|
287
|
+
* second answer is the one that decides where the bytes go — so a name that
|
|
288
|
+
* alternates records walks straight through a check that passed a moment
|
|
289
|
+
* earlier. Screening inside the resolver the socket itself calls means there
|
|
290
|
+
* is one resolution, and the address that was screened is the address that
|
|
291
|
+
* gets connected to.
|
|
292
|
+
*
|
|
293
|
+
* Every returned address is screened, not just the one chosen. A record set
|
|
294
|
+
* mixing a public address with an inward one is the ordinary shape of this
|
|
295
|
+
* attack, and screening only the winner makes the outcome depend on which
|
|
296
|
+
* record the resolver happened to order first.
|
|
297
|
+
*/
|
|
298
|
+
export function createScreeningLookup(
|
|
299
|
+
options: ScreeningLookupOptions = {},
|
|
300
|
+
isExempt: (host: string, patterns: readonly string[]) => boolean = () => false,
|
|
301
|
+
): (hostname: string, opts: unknown, callback: LookupCallback) => void {
|
|
302
|
+
const resolver = options.resolve ?? platformResolver
|
|
303
|
+
const exemptions = options.allowInwardFor ?? []
|
|
304
|
+
|
|
305
|
+
return (hostname, opts, callback) => {
|
|
306
|
+
const wantsAll =
|
|
307
|
+
typeof opts === 'object' && opts !== null && (opts as { all?: boolean }).all === true
|
|
308
|
+
const family =
|
|
309
|
+
typeof opts === 'number'
|
|
310
|
+
? opts
|
|
311
|
+
: typeof opts === 'object' && opts !== null
|
|
312
|
+
? ((opts as { family?: number }).family ?? 0)
|
|
313
|
+
: 0
|
|
314
|
+
|
|
315
|
+
resolver(hostname, { all: true, verbatim: true }, (err, addresses) => {
|
|
316
|
+
if (err) {
|
|
317
|
+
callback(err, '', 0)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const found = addresses ?? []
|
|
321
|
+
if (found.length === 0) {
|
|
322
|
+
const empty: NodeJS.ErrnoException = new Error(
|
|
323
|
+
`Egress denied: ${hostname} resolved to no addresses.`,
|
|
324
|
+
)
|
|
325
|
+
empty.code = 'ENOTFOUND'
|
|
326
|
+
callback(empty, '', 0)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!(exemptions.length > 0 && isExempt(hostname, exemptions))) {
|
|
331
|
+
for (const candidate of found) {
|
|
332
|
+
const reason = blockedAddressReason(candidate.address)
|
|
333
|
+
if (reason) {
|
|
334
|
+
callback(new EgressAddressDenied(hostname, candidate.address, reason), '', 0)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const usable = family === 0 ? found : found.filter((a) => a.family === family)
|
|
341
|
+
if (usable.length === 0) {
|
|
342
|
+
const mismatch: NodeJS.ErrnoException = new Error(
|
|
343
|
+
`Egress denied: ${hostname} has no address in the requested family.`,
|
|
344
|
+
)
|
|
345
|
+
mismatch.code = 'ENOTFOUND'
|
|
346
|
+
callback(mismatch, '', 0)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (wantsAll) {
|
|
351
|
+
callback(null, usable)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const first = usable[0]
|
|
355
|
+
if (!first) {
|
|
356
|
+
const none: NodeJS.ErrnoException = new Error(
|
|
357
|
+
`Egress denied: ${hostname} resolved to no usable address.`,
|
|
358
|
+
)
|
|
359
|
+
none.code = 'ENOTFOUND'
|
|
360
|
+
callback(none, '', 0)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
callback(null, first.address, first.family)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
}
|
package/src/egress/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
blockedAddressReason,
|
|
3
|
+
blockedLiteralReason,
|
|
4
|
+
createScreeningLookup,
|
|
5
|
+
EgressAddressDenied,
|
|
6
|
+
} from './address.js'
|
|
7
|
+
export type { AddressResolver, ScreeningLookupOptions } from './address.js'
|
|
1
8
|
export { isHostAllowed, splitAuthority } from './allowlist.js'
|
|
2
9
|
export { EgressProxy } from './proxy.js'
|
|
3
10
|
export type {
|
package/src/egress/proxy.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { request as httpsRequest } from 'node:https'
|
|
|
4
4
|
import { connect as netConnect } from 'node:net'
|
|
5
5
|
import type { Duplex } from 'node:stream'
|
|
6
6
|
|
|
7
|
+
import { EgressAddressDenied, blockedLiteralReason, createScreeningLookup } from './address.js'
|
|
8
|
+
import type { ScreeningLookupOptions } from './address.js'
|
|
7
9
|
import { isHostAllowed, splitAuthority } from './allowlist.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -59,6 +61,18 @@ export interface EgressProxyOptions {
|
|
|
59
61
|
*/
|
|
60
62
|
readonly upgradeToHttps?: boolean
|
|
61
63
|
readonly onDenied?: (host: string, reason: string) => void
|
|
64
|
+
/**
|
|
65
|
+
* Allowlisted hosts permitted to resolve to an inward address anyway.
|
|
66
|
+
*
|
|
67
|
+
* Matched by the allowlist's own rules, so `.internal.example` covers
|
|
68
|
+
* subdomains. Per host on purpose: an operator who genuinely proxies to
|
|
69
|
+
* one service on a private network needs that one exempted, and a global
|
|
70
|
+
* switch to get it would hand every other allowlisted name the same
|
|
71
|
+
* reach — which is the hole this screen exists to close.
|
|
72
|
+
*/
|
|
73
|
+
readonly allowInwardFor?: readonly string[]
|
|
74
|
+
/** Injected in tests. Defaults to the platform resolver. */
|
|
75
|
+
readonly resolveAddresses?: ScreeningLookupOptions['resolve']
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
export interface RunningEgressProxy {
|
|
@@ -79,12 +93,28 @@ export class EgressProxy {
|
|
|
79
93
|
private readonly onDenied: ((host: string, reason: string) => void) | undefined
|
|
80
94
|
/** See the loop guard in `listen`. */
|
|
81
95
|
private selfPort: number | undefined
|
|
96
|
+
/**
|
|
97
|
+
* The resolver both paths connect through.
|
|
98
|
+
*
|
|
99
|
+
* Held once rather than built per request so there is exactly one place
|
|
100
|
+
* the address screen can be bypassed, and it is visible from here.
|
|
101
|
+
*/
|
|
102
|
+
private readonly lookup: ReturnType<typeof createScreeningLookup>
|
|
103
|
+
private readonly inwardAllowed: readonly string[]
|
|
82
104
|
|
|
83
105
|
constructor(options: EgressProxyOptions) {
|
|
84
106
|
this.resolveAllowed = options.allowedHosts
|
|
85
107
|
this.credentials = options.credentials ?? []
|
|
86
108
|
this.upgradeToHttps = options.upgradeToHttps ?? true
|
|
87
109
|
this.onDenied = options.onDenied
|
|
110
|
+
this.inwardAllowed = options.allowInwardFor ?? []
|
|
111
|
+
this.lookup = createScreeningLookup(
|
|
112
|
+
{
|
|
113
|
+
...(options.allowInwardFor ? { allowInwardFor: options.allowInwardFor } : {}),
|
|
114
|
+
...(options.resolveAddresses ? { resolve: options.resolveAddresses } : {}),
|
|
115
|
+
},
|
|
116
|
+
isHostAllowed,
|
|
117
|
+
)
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
async listen(port = 0): Promise<RunningEgressProxy> {
|
|
@@ -143,6 +173,18 @@ export class EgressProxy {
|
|
|
143
173
|
this.onDenied?.(host, reason)
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Why this target may not be dialled as written, or `null`.
|
|
178
|
+
*
|
|
179
|
+
* Covers the literal-address case only; a name is screened inside
|
|
180
|
+
* `this.lookup`, which the socket calls. Both are needed — see
|
|
181
|
+
* `blockedLiteralReason` for why one cannot cover the other.
|
|
182
|
+
*/
|
|
183
|
+
private literalDenial(host: string): string | null {
|
|
184
|
+
if (this.inwardAllowed.length > 0 && isHostAllowed(host, this.inwardAllowed)) return null
|
|
185
|
+
return blockedLiteralReason(host)
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
/** Plain HTTP. The only path where a credential can be stamped on. */
|
|
147
189
|
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
190
|
const target = parseTarget(req)
|
|
@@ -170,6 +212,20 @@ export class EgressProxy {
|
|
|
170
212
|
return
|
|
171
213
|
}
|
|
172
214
|
|
|
215
|
+
const literal = this.literalDenial(target.host)
|
|
216
|
+
if (literal) {
|
|
217
|
+
this.deny(target.host, `is a ${literal} address`)
|
|
218
|
+
res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
|
|
219
|
+
// Refused BEFORE the credential is looked up, let alone stamped.
|
|
220
|
+
// The ordering is the point: on this path the token goes on the
|
|
221
|
+
// headers a few lines below, so a check that ran after it would be
|
|
222
|
+
// deciding whether to send a request that already carried it.
|
|
223
|
+
res.end(
|
|
224
|
+
`Egress denied: ${target.host} is a ${literal} address, which is not reachable from a sandbox.\n`,
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
const headers = { ...req.headers }
|
|
174
230
|
// The proxy re-issues the request, so hop-by-hop headers about the
|
|
175
231
|
// hop that just ended must not be forwarded.
|
|
@@ -192,6 +248,11 @@ export class EgressProxy {
|
|
|
192
248
|
method: req.method,
|
|
193
249
|
path: target.path,
|
|
194
250
|
headers,
|
|
251
|
+
// `host` stays the NAME so SNI and certificate validation still
|
|
252
|
+
// check the name the allowlist approved; only the address the
|
|
253
|
+
// socket dials is screened. Swapping in the address here would
|
|
254
|
+
// break TLS verification, which is the wrong way to fix this.
|
|
255
|
+
lookup: this.lookup,
|
|
195
256
|
},
|
|
196
257
|
(response) => {
|
|
197
258
|
res.writeHead(response.statusCode ?? 502, response.headers)
|
|
@@ -200,6 +261,17 @@ export class EgressProxy {
|
|
|
200
261
|
)
|
|
201
262
|
|
|
202
263
|
upstream.on('error', (err) => {
|
|
264
|
+
// An address denial is a policy refusal, not a network fault, and
|
|
265
|
+
// telling them apart is the difference between an agent that stops
|
|
266
|
+
// and one that retries a forbidden host forever. The credential is
|
|
267
|
+
// already on `headers` at this point and has still not left the
|
|
268
|
+
// process: the socket never connected, so nothing was written.
|
|
269
|
+
if (err instanceof EgressAddressDenied) {
|
|
270
|
+
this.deny(err.host, `resolves to a ${err.reason} address`)
|
|
271
|
+
if (!res.headersSent) res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
|
|
272
|
+
res.end(`${err.message}\n`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
203
275
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
|
|
204
276
|
res.end(`Upstream request failed: ${err.message}\n`)
|
|
205
277
|
})
|
|
@@ -216,6 +288,18 @@ export class EgressProxy {
|
|
|
216
288
|
* agent sends anywhere, a strictly larger risk than the one being
|
|
217
289
|
* mitigated. A workload that needs brokering speaks plain HTTP to the
|
|
218
290
|
* proxy and lets it upgrade upstream.
|
|
291
|
+
*
|
|
292
|
+
* **And the allowlist here is a check on the name in the CONNECT line,
|
|
293
|
+
* which is the only thing about this tunnel that is ever in clear text.**
|
|
294
|
+
* The address screen makes it a check on where that name goes, which is a
|
|
295
|
+
* real bound — a permitted name can no longer be a route to the host's own
|
|
296
|
+
* network. It is not a check on what travels afterwards. Inside the tunnel
|
|
297
|
+
* the bytes are the caller's, and the name the caller puts in its own TLS
|
|
298
|
+
* handshake or `Host` header is not visible to this process, so against a
|
|
299
|
+
* determined caller running inside the sandbox this path bounds the
|
|
300
|
+
* DESTINATION and nothing else. Say so plainly rather than let a reader
|
|
301
|
+
* infer that a tunnel to an allowlisted host carries only allowlisted
|
|
302
|
+
* traffic.
|
|
219
303
|
*/
|
|
220
304
|
private async handleConnect(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
221
305
|
const { host, port } = splitAuthority(req.url ?? '')
|
|
@@ -229,14 +313,34 @@ export class EgressProxy {
|
|
|
229
313
|
return
|
|
230
314
|
}
|
|
231
315
|
|
|
232
|
-
const
|
|
316
|
+
const literal = this.literalDenial(host)
|
|
317
|
+
if (literal) {
|
|
318
|
+
this.deny(host, `is a ${literal} address`)
|
|
319
|
+
socket.write(
|
|
320
|
+
`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host} is a ${literal} address, which is not reachable from a sandbox.\n`,
|
|
321
|
+
)
|
|
322
|
+
socket.end()
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Same screened resolver as the plain path. The tunnel carries no
|
|
327
|
+
// brokered credential, so the loss here is reach rather than a token —
|
|
328
|
+
// but an allowlisted name pointing inward still turns this proxy into
|
|
329
|
+
// a route to the host's own network, which is what a sandbox is for.
|
|
330
|
+
const upstream = netConnect({ port: port ?? 443, host, lookup: this.lookup }, () => {
|
|
233
331
|
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
234
332
|
if (head.length > 0) upstream.write(head)
|
|
235
333
|
upstream.pipe(socket)
|
|
236
334
|
socket.pipe(upstream)
|
|
237
335
|
})
|
|
238
336
|
|
|
239
|
-
upstream.on('error', () => {
|
|
337
|
+
upstream.on('error', (err) => {
|
|
338
|
+
if (err instanceof EgressAddressDenied) {
|
|
339
|
+
this.deny(err.host, `resolves to a ${err.reason} address`)
|
|
340
|
+
socket.write(
|
|
341
|
+
`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\n${err.message}\n`,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
240
344
|
socket.end()
|
|
241
345
|
})
|
|
242
346
|
socket.on('error', () => {
|
package/src/index.ts
CHANGED
|
@@ -210,6 +210,22 @@ export interface ContainerBackendConfig {
|
|
|
210
210
|
* separately by `EgressPolicy`.
|
|
211
211
|
*/
|
|
212
212
|
readonly network?: 'none' | 'bridge' | string
|
|
213
|
+
/**
|
|
214
|
+
* Allowlisted hosts permitted to resolve to an inward address anyway.
|
|
215
|
+
*
|
|
216
|
+
* The egress boundary refuses a host that resolves to loopback, a
|
|
217
|
+
* private range, or the link-local block cloud metadata services answer
|
|
218
|
+
* on — whatever the allowlist says, because an allowlisted name whose
|
|
219
|
+
* DNS someone else controls is a permitted spelling rather than a
|
|
220
|
+
* permitted destination. A deployment that genuinely proxies to one
|
|
221
|
+
* service on a private network names that service here.
|
|
222
|
+
*
|
|
223
|
+
* Per host, matched by the allowlist's own rules so `.internal.example`
|
|
224
|
+
* covers subdomains. There is deliberately no switch that turns the
|
|
225
|
+
* screen off: one would hand every other allowlisted name the same
|
|
226
|
+
* reach, which is the hole the screen exists to close.
|
|
227
|
+
*/
|
|
228
|
+
readonly allowInwardFor?: readonly string[]
|
|
213
229
|
/**
|
|
214
230
|
* Optional `--label key=value` pairs applied to the spawned
|
|
215
231
|
* container. Hosts use this to make the container findable from
|
|
@@ -549,6 +565,7 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
549
565
|
? { hostReachability: backend.hostReachability }
|
|
550
566
|
: {}),
|
|
551
567
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
568
|
+
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
552
569
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
553
570
|
})
|
|
554
571
|
}
|
|
@@ -599,6 +616,7 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
599
616
|
? { hostReachability: backend.hostReachability }
|
|
600
617
|
: {}),
|
|
601
618
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
619
|
+
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
602
620
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
603
621
|
})
|
|
604
622
|
}
|