Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,14 @@ These two are read at each CONNECT, so `updateConfig` changes them for the tunne
- `network.stripResponseHeaders` - Response headers never passed back to the sandboxed client, matched case-insensitively with `-`, `_` and `.` folded (e.g. `["set-cookie"]`).
- `network.refuseOpaqueTunnels` - Refuse every tunnel `filterRequest` cannot see into: an HTTP CONNECT that would not be TLS-terminated or carries no TLS, and every SOCKS CONNECT. Requires `tlsTerminate` to serve HTTPS at all, and stops CONNECT-carried SSH (such as `GIT_SSH_COMMAND`) through the proxy. Refusals are recorded as violations.
- `network.requireHostMatch` - Answer 421 to a request whose Host header, TLS server name or absolute-form authority does not name the host and port it is sent to, before `filterRequest` is asked. Refusals are recorded as violations.
- `network.ambiguousRequestTarget` - What becomes of a request whose target is ambiguous: normalised (dot segments and `%2e` resolved, `\` in the path read as `/`, a run of leading slashes collapsed to one) it names another path than as the client spelled it. Upstreams differ in how they read such a spelling, so a policy that judged one reading can be got around through another.
- `"refuse"` (default) - Answered 400 with `X-Proxy-Error: ambiguous-request-target` before `filterRequest` is asked. Nothing is forwarded, and the refusal is recorded as a violation.
- `"asSpelled"` - Forwarded as the client spelled it. The caller takes on that its `filterRequest` holds for every reading an upstream may make of the spelling: `/public/../admin` is `/admin` to one that resolves it, `/admin/../public/x` is under `/admin/` to one that does not, and `/a\b/../admin`, normalised `/a/admin`, is `/admin` to one that keeps the `\`. Needs Node, or Bun 1.4 or later: an older Bun's HTTP client resolves dot segments on the way out.
- `"normalize"` - Forwarded normalised. A signature that covers the path no longer verifies (or, where the proxy re-signs for a masked AWS credential, is valid for the other path), and an upstream that takes the path literally, such as an object store where `a/../b` is a key, acts on another resource than the client named.

While `filterRequest` is set, the request target is normalised once (dot segments and `%2e` resolved, a run of leading slashes collapsed to one), and that value is both what the callback sees and what is forwarded, on plain HTTP and inside a terminated tunnel. A target that is not origin form, an absolute `http(s)` URI, or `*` for `OPTIONS` is answered 400.
While `filterRequest` is set, on plain HTTP and inside a terminated tunnel, the target goes upstream byte for byte as the client spelled it, without a fragment (except under `"normalize"`), and the callback's second argument carries both forms, `requestTarget` as spelled and `normalizedRequestTarget`, and `requestTargetIsAmbiguous`: under `"asSpelled"` a callback can refuse an ambiguous target itself, for the hosts and with the status it chooses. `request.url` is a parsed URL, so, as in earlier releases, its path has dot segments already resolved and is not the spelling. Not ambiguous, and forwarded as spelled: `//` after the first segment, `%2F`, `;` parameters, dots in the query, a character the URL parser would only escape (`{`, `"`, `'`), and an empty query. A byte outside ASCII, which only Bun's listener for plain HTTP lets through, is percent-encoded in `requestTarget` and on the wire, as in earlier releases. A `#` and what follows it are cut off first, in every mode: a fragment is no part of a request target, but some clients leave it in the absolute form they send a proxy, and upstreams differ in what they make of one. So the callback, a signature, the upstream and a violation line all get the target without it; `%23` is data and stays.

Answered 400 in every mode, and recorded as a violation: a target that is not origin form, an absolute `http(s)` URI, or `*` for `OPTIONS`; and one with bytes that are not UTF-8, which the runtime has replaced by the time the proxy reads them. So is, inside a terminated tunnel and with or without `filterRequest`, a request the runtime's own HTTP parser refuses, which ends the tunnel: a raw byte outside ASCII in the target (`/café` has to be sent as `/caf%C3%A9`), a space, a malformed header.

While `filterRequest` is set, `TRACE` and `TRACK` requests are answered 405 before it is asked, on plain HTTP and inside a terminated tunnel, because their response would echo a header the decision set back to the client. Node's and Bun 1.4's HTTP parsers already answer `TRACK` 400 before the proxy sees it; under Bun 1.3 the connection is closed without an answer. Refusals are recorded as violations.

Expand Down
4 changes: 3 additions & 1 deletion docs/srt-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ ids start at 1 and strictly increase.
- `path` and `query` are the request target **as the client spelled it**:
dot segments and percent escapes are not resolved. A decider that matches
paths exactly therefore refuses an unusual spelling of an allowed path.
`query` is left out when there is none.
One that matches by prefix has to refuse dot segments (`..`, `%2e%2e`)
itself: `/public/../admin` begins with `/public/`, and the upstream may
resolve it. `query` is left out when there is none.
- `headers` maps each lower-case header name to its values, in order, with
`Host` apart.

Expand Down
4 changes: 2 additions & 2 deletions src/sandbox/aws-sigv4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ export function uriEncode(s: string): string {
* awslabs/aws-sigv4-proxy, which disables path escaping for s3 only.
*
* rawPath is the target the proxy forwards, so the signature covers the
* path on the wire: the client's bytes unchanged when no filterRequest is
* configured, the normalized target when one is.
* path on the wire: the client's bytes unchanged, or the normalized target
* under ambiguousRequestTarget 'normalize'.
*/
export function canonicalUri(rawPath: string, service: string): string {
if (rawPath === '') return '/'
Expand Down
55 changes: 43 additions & 12 deletions src/sandbox/http-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
import { CRL_PATH, type MitmCA } from './mitm-ca.js'
import {
decideAndRespond,
normalizeRequestTarget,
readRequestTarget,
requestTargetAsSpelled,
respondRefusedTarget,
type AmbiguousRequestTarget,
type DenyMark,
applyHeaderEdits,
notifyResponse,
Expand Down Expand Up @@ -305,6 +307,17 @@ export interface HttpProxyServerOptions {
*/
requestTargetAsSpelled?: boolean

/**
* What becomes of a request filterRequest would judge whose target is
* ambiguous (see readRequestTarget), on both paths. `'refuse'`, the
* default: 400 before filterRequest is asked, reported through
* onFilterRequestDenied. `'asSpelled'`: forwarded as the client spelled
* it; filterRequest has to hold for every reading. `'normalize'`: forwarded
* normalized, which breaks a signature over the path and names another
* resource to an upstream that takes the path literally.
*/
ambiguousRequestTarget?: AmbiguousRequestTarget

/**
* Refuse, with 400 and before the allowlist is consulted, a request
* whose destination host is not spelled in plain ASCII: letters, digits,
Expand Down Expand Up @@ -815,6 +828,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
foldHopByHop: options.foldHopByHop,
denyHeader: options.denyHeader,
requestTargetAsSpelled: options.requestTargetAsSpelled,
ambiguousRequestTarget: options.ambiguousRequestTarget,
byteBudget,
timeouts: options.tunnelTimeouts,
onEstablished: () => clearTimeout(deadline),
Expand Down Expand Up @@ -1075,23 +1089,38 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
// forwarding the client's raw req.url. This ensures the upstream proxy
// sees exactly the host we allowlist-checked, closing URL-parser
// differential bypasses.
// With filterRequest, the request target is normalized before the
// hook sees it and that normalized target is what is forwarded;
// without it, the parsed path and query are forwarded unchanged.
// requestTargetAsSpelled comes first: it forwards the target as the
// client spelled it, hook or no hook.
// With filterRequest, the hook gets the target as spelled and
// normalized, the spelling is what is forwarded, and a target whose
// two forms can name different resources is refused unless
// ambiguousRequestTarget says which to send (see forwardUpstream in
// tls-terminate-proxy.ts); without it, the parsed path and query are
// forwarded unchanged. requestTargetAsSpelled comes first: it forwards
// the target as the client spelled it, hook or no hook.
const read =
options.filterRequest && !options.requestTargetAsSpelled
? readRequestTarget(
req.url!,
req.method,
options.ambiguousRequestTarget,
)
: undefined
const requestTarget = options.requestTargetAsSpelled
? requestTargetAsSpelled(req.url!, { absolute: true })
: options.filterRequest
? normalizeRequestTarget(req.url!, req.method)
: read
? read.forwarded
: `${url.pathname}${url.search}`
if (requestTarget === undefined) {
respondDenied(
const reason = respondRefusedTarget(
res,
'malformed request-target',
read?.refusal,
markFor('bad_request'),
400,
)
options.onFilterRequestDenied?.({
method: req.method ?? 'GET',
url: `${url.protocol}//${authority}${read?.spelled ?? ''}`,
reason,
encodedCommand: auth.encodedCommand,
})
return
}
const absUrl = `${url.protocol}//${authority}${requestTarget}`
Expand Down Expand Up @@ -1160,7 +1189,9 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
: undefined,
{
target: { host: hostname, port },
requestTarget,
requestTarget: read?.spelled ?? requestTarget,
normalizedRequestTarget: read?.normalized,
requestTargetIsAmbiguous: read?.ambiguous,
rawHeaders: [...req.rawHeaders],
scheme: 'http',
},
Expand Down
11 changes: 11 additions & 0 deletions src/sandbox/mux-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,18 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer {
const upstream = backendSocketPath
? connect(backendSocketPath)
: connect(backendTcpPort!, '127.0.0.1')
let answered = false
upstream.once('data', () => {
answered = true
})
upstream.on('error', err => {
// The 502 is an answer, for a client that has had none. One that has
// may be in the middle of anything, a TLS tunnel included, where clear
// text is noise. The 'close' that follows ends it.
if (answered) {
logForDebugging(`mux: HTTP backend error: ${(err as Error).message}`)
return
}
// Surface a 502 with the errno code so callers (and CI logs) can tell
// *why* the backend was unreachable instead of an opaque empty-reply.
// Full error (including path) goes to debug logging only — the client
Expand Down
Loading
Loading