@namzu/sandbox 2.0.3 → 3.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.
@@ -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 upstream = netConnect(port ?? 443, host, () => {
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
  }