@specific.dev/spectest 0.93.1 → 0.94.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/dist/components/emulate/entry.mjs +2 -3
- package/dist/components/s3.d.ts +3 -3
- package/dist/daemon.js +5 -25
- package/dist/harness/http-proxy.d.ts +7 -2
- package/dist/harness/http-proxy.js +17 -3
- package/dist/harness/ws-bridge.d.ts +1 -3
- package/dist/harness/ws-bridge.js +3 -16
- package/package.json +1 -1
- package/src/components/emulate/entry.mjs +2 -3
- package/src/components/s3.ts +3 -3
- package/src/daemon.ts +5 -21
- package/src/harness/http-proxy.test.ts +66 -4
- package/src/harness/http-proxy.ts +19 -3
- package/src/harness/ws-bridge.test.ts +8 -12
- package/src/harness/ws-bridge.ts +3 -16
|
@@ -179,9 +179,8 @@ Bun.serve({
|
|
|
179
179
|
// own IP and never sends a provider name.
|
|
180
180
|
if (url.pathname === "/healthz") return new Response("ok\n");
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
|
|
182
|
+
// Ingress preserves Host and also stamps X-Forwarded-Host with the
|
|
183
|
+
// requested authority. Host covers direct calls to the emulator.
|
|
185
184
|
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
|
|
186
185
|
.toLowerCase()
|
|
187
186
|
.replace(/:\d+$/, "");
|
package/dist/components/s3.d.ts
CHANGED
|
@@ -17,9 +17,9 @@ export interface S3Options {
|
|
|
17
17
|
* via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
|
|
18
18
|
* the store is always reachable at `http://<key>:9090` regardless. Set this
|
|
19
19
|
* only when the app under test hardcodes a specific prod S3 endpoint it can't
|
|
20
|
-
* be told to override (
|
|
21
|
-
*
|
|
22
|
-
* store's plain HTTP
|
|
20
|
+
* be told to override (a provider's storage host; requests must still use
|
|
21
|
+
* path-style addressing): the daemon mints a cert for each host and proxies to the
|
|
22
|
+
* store's plain HTTP while preserving the requested Host.
|
|
23
23
|
*/
|
|
24
24
|
hosts?: string[];
|
|
25
25
|
/** Access key id the helper client signs with. The store ignores SigV4, so
|
package/dist/daemon.js
CHANGED
|
@@ -56,7 +56,7 @@ import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
|
56
56
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
57
57
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
58
58
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
59
|
-
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
59
|
+
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, upstreamRequestHeaders, } from "./harness/http-proxy.js";
|
|
60
60
|
import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
61
61
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
62
62
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
@@ -2098,8 +2098,6 @@ server, service, port, listenerLabel, proto) {
|
|
|
2098
2098
|
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2099
2099
|
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2100
2100
|
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2101
|
-
service,
|
|
2102
|
-
port,
|
|
2103
2101
|
proto,
|
|
2104
2102
|
clientIp: ingressClientIp(server, req, proto),
|
|
2105
2103
|
});
|
|
@@ -2127,28 +2125,10 @@ server, service, port, listenerLabel, proto) {
|
|
|
2127
2125
|
}
|
|
2128
2126
|
return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
|
|
2129
2127
|
}
|
|
2130
|
-
const fwdHeaders =
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
fwdHeaders.append(k, v);
|
|
2135
|
-
}
|
|
2136
|
-
// Standard reverse-proxy provenance headers: the upstream sees the
|
|
2137
|
-
// public scheme/host it was reached through and the client's address,
|
|
2138
|
-
// even though we rewrite Host below to the service-net name.
|
|
2139
|
-
const clientIp = ingressClientIp(server, req, proto);
|
|
2140
|
-
const priorXff = req.headers.get("x-forwarded-for");
|
|
2141
|
-
const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
|
|
2142
|
-
if (xff)
|
|
2143
|
-
fwdHeaders.set("x-forwarded-for", xff);
|
|
2144
|
-
fwdHeaders.set("x-forwarded-proto", proto);
|
|
2145
|
-
const publicHost = req.headers.get("host");
|
|
2146
|
-
if (publicHost)
|
|
2147
|
-
fwdHeaders.set("x-forwarded-host", publicHost);
|
|
2148
|
-
// Override Host so the upstream sees its own service-net name, not
|
|
2149
|
-
// the public hostname. Lets origin servers that vhost by Host header
|
|
2150
|
-
// continue to find the right virtual host.
|
|
2151
|
-
fwdHeaders.set("host", `${service}:${port}`);
|
|
2128
|
+
const fwdHeaders = upstreamRequestHeaders(req.headers, {
|
|
2129
|
+
proto,
|
|
2130
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2131
|
+
});
|
|
2152
2132
|
// Fresh connection per upstream request — never reuse a pooled
|
|
2153
2133
|
// keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
|
|
2154
2134
|
// to 5s) close pooled connections under Bun's fetch, and the next
|
|
@@ -12,13 +12,18 @@
|
|
|
12
12
|
* Forwarding `transfer-encoding` or `content-length` from a re-encoded
|
|
13
13
|
* body is the classic way to produce a response the client cannot frame;
|
|
14
14
|
* forwarding `connection` leaks our keep-alive intent to the upstream.
|
|
15
|
-
* `host`
|
|
16
|
-
* the service it actually is, not the public name the client asked for.
|
|
15
|
+
* `host` identifies the requested origin and must survive the proxy hop.
|
|
17
16
|
*/
|
|
18
17
|
export declare const HOP_BY_HOP_HEADERS: ReadonlySet<string>;
|
|
19
18
|
export declare function isHopByHop(name: string): boolean;
|
|
20
19
|
/** Copy headers, dropping the ones that belong to this connection. */
|
|
21
20
|
export declare function forwardableHeaders(headers: Headers): Headers;
|
|
21
|
+
/** Preserve the requested Host (including its port) while dialing the service's
|
|
22
|
+
* internal address. HTTP and WebSocket forwarding share this policy. */
|
|
23
|
+
export declare function upstreamRequestHeaders(headers: Headers, opts: {
|
|
24
|
+
proto: string;
|
|
25
|
+
clientIp?: string;
|
|
26
|
+
}): Headers;
|
|
22
27
|
/**
|
|
23
28
|
* A CORS preflight is an `OPTIONS` carrying both `Origin` and
|
|
24
29
|
* `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* Forwarding `transfer-encoding` or `content-length` from a re-encoded
|
|
13
13
|
* body is the classic way to produce a response the client cannot frame;
|
|
14
14
|
* forwarding `connection` leaks our keep-alive intent to the upstream.
|
|
15
|
-
* `host`
|
|
16
|
-
* the service it actually is, not the public name the client asked for.
|
|
15
|
+
* `host` identifies the requested origin and must survive the proxy hop.
|
|
17
16
|
*/
|
|
18
17
|
export const HOP_BY_HOP_HEADERS = new Set([
|
|
19
18
|
"connection",
|
|
@@ -24,7 +23,6 @@ export const HOP_BY_HOP_HEADERS = new Set([
|
|
|
24
23
|
"trailers",
|
|
25
24
|
"transfer-encoding",
|
|
26
25
|
"upgrade",
|
|
27
|
-
"host",
|
|
28
26
|
]);
|
|
29
27
|
export function isHopByHop(name) {
|
|
30
28
|
return HOP_BY_HOP_HEADERS.has(name.toLowerCase());
|
|
@@ -38,6 +36,22 @@ export function forwardableHeaders(headers) {
|
|
|
38
36
|
});
|
|
39
37
|
return out;
|
|
40
38
|
}
|
|
39
|
+
/** Preserve the requested Host (including its port) while dialing the service's
|
|
40
|
+
* internal address. HTTP and WebSocket forwarding share this policy. */
|
|
41
|
+
export function upstreamRequestHeaders(headers, opts) {
|
|
42
|
+
const out = forwardableHeaders(headers);
|
|
43
|
+
const priorXff = headers.get("x-forwarded-for");
|
|
44
|
+
const xff = opts.clientIp
|
|
45
|
+
? priorXff ? `${priorXff}, ${opts.clientIp}` : opts.clientIp
|
|
46
|
+
: priorXff;
|
|
47
|
+
if (xff)
|
|
48
|
+
out.set("x-forwarded-for", xff);
|
|
49
|
+
out.set("x-forwarded-proto", opts.proto);
|
|
50
|
+
const host = headers.get("host");
|
|
51
|
+
if (host)
|
|
52
|
+
out.set("x-forwarded-host", host);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
41
55
|
/**
|
|
42
56
|
* A CORS preflight is an `OPTIONS` carrying both `Origin` and
|
|
43
57
|
* `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
|
|
@@ -33,11 +33,9 @@ export declare function parseSubprotocols(header: string | null | undefined): st
|
|
|
33
33
|
* The headers the upstream handshake carries: everything forwardable from
|
|
34
34
|
* the client's request (cookies, authorization, origin, user-agent …),
|
|
35
35
|
* minus the client's own handshake fields, plus the proxy provenance
|
|
36
|
-
* headers and
|
|
36
|
+
* headers and the original Host.
|
|
37
37
|
*/
|
|
38
38
|
export declare function upstreamHandshakeHeaders(req: Headers, opts: {
|
|
39
|
-
service: string;
|
|
40
|
-
port: number;
|
|
41
39
|
proto: string;
|
|
42
40
|
clientIp?: string;
|
|
43
41
|
}): Record<string, string>;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
20
|
* without a VM.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { upstreamRequestHeaders } from "./http-proxy.js";
|
|
23
23
|
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
24
24
|
* WebSocket client mints its own key and version, and the protocol list
|
|
25
25
|
* goes through the constructor option, so these never travel as plain
|
|
@@ -46,28 +46,15 @@ export function parseSubprotocols(header) {
|
|
|
46
46
|
* The headers the upstream handshake carries: everything forwardable from
|
|
47
47
|
* the client's request (cookies, authorization, origin, user-agent …),
|
|
48
48
|
* minus the client's own handshake fields, plus the proxy provenance
|
|
49
|
-
* headers and
|
|
49
|
+
* headers and the original Host.
|
|
50
50
|
*/
|
|
51
51
|
export function upstreamHandshakeHeaders(req, opts) {
|
|
52
52
|
const out = {};
|
|
53
|
-
|
|
53
|
+
upstreamRequestHeaders(req, opts).forEach((value, key) => {
|
|
54
54
|
if (HANDSHAKE_HEADERS.has(key))
|
|
55
55
|
return;
|
|
56
56
|
out[key] = value;
|
|
57
57
|
});
|
|
58
|
-
const priorXff = req.get("x-forwarded-for");
|
|
59
|
-
const xff = opts.clientIp
|
|
60
|
-
? priorXff
|
|
61
|
-
? `${priorXff}, ${opts.clientIp}`
|
|
62
|
-
: opts.clientIp
|
|
63
|
-
: priorXff;
|
|
64
|
-
if (xff)
|
|
65
|
-
out["x-forwarded-for"] = xff;
|
|
66
|
-
out["x-forwarded-proto"] = opts.proto;
|
|
67
|
-
const publicHost = req.get("host");
|
|
68
|
-
if (publicHost)
|
|
69
|
-
out["x-forwarded-host"] = publicHost;
|
|
70
|
-
out["host"] = `${opts.service}:${opts.port}`;
|
|
71
58
|
return out;
|
|
72
59
|
}
|
|
73
60
|
/**
|
package/package.json
CHANGED
|
@@ -179,9 +179,8 @@ Bun.serve({
|
|
|
179
179
|
// own IP and never sends a provider name.
|
|
180
180
|
if (url.pathname === "/healthz") return new Response("ok\n");
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
|
|
182
|
+
// Ingress preserves Host and also stamps X-Forwarded-Host with the
|
|
183
|
+
// requested authority. Host covers direct calls to the emulator.
|
|
185
184
|
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
|
|
186
185
|
.toLowerCase()
|
|
187
186
|
.replace(/:\d+$/, "");
|
package/src/components/s3.ts
CHANGED
|
@@ -20,9 +20,9 @@ export interface S3Options {
|
|
|
20
20
|
* via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
|
|
21
21
|
* the store is always reachable at `http://<key>:9090` regardless. Set this
|
|
22
22
|
* only when the app under test hardcodes a specific prod S3 endpoint it can't
|
|
23
|
-
* be told to override (
|
|
24
|
-
*
|
|
25
|
-
* store's plain HTTP
|
|
23
|
+
* be told to override (a provider's storage host; requests must still use
|
|
24
|
+
* path-style addressing): the daemon mints a cert for each host and proxies to the
|
|
25
|
+
* store's plain HTTP while preserving the requested Host.
|
|
26
26
|
*/
|
|
27
27
|
hosts?: string[];
|
|
28
28
|
/** Access key id the helper client signs with. The store ignores SigV4, so
|
package/src/daemon.ts
CHANGED
|
@@ -130,6 +130,7 @@ import {
|
|
|
130
130
|
augmentCorsResponse,
|
|
131
131
|
corsPreflightResponse,
|
|
132
132
|
isCorsPreflight,
|
|
133
|
+
upstreamRequestHeaders,
|
|
133
134
|
} from "./harness/http-proxy.js";
|
|
134
135
|
import {
|
|
135
136
|
WS_BRIDGE_HANDLERS,
|
|
@@ -2579,8 +2580,6 @@ async function proxyToService(
|
|
|
2579
2580
|
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2580
2581
|
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2581
2582
|
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2582
|
-
service,
|
|
2583
|
-
port,
|
|
2584
2583
|
proto,
|
|
2585
2584
|
clientIp: ingressClientIp(server, req, proto),
|
|
2586
2585
|
});
|
|
@@ -2614,25 +2613,10 @@ async function proxyToService(
|
|
|
2614
2613
|
);
|
|
2615
2614
|
}
|
|
2616
2615
|
|
|
2617
|
-
const fwdHeaders =
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
}
|
|
2622
|
-
// Standard reverse-proxy provenance headers: the upstream sees the
|
|
2623
|
-
// public scheme/host it was reached through and the client's address,
|
|
2624
|
-
// even though we rewrite Host below to the service-net name.
|
|
2625
|
-
const clientIp = ingressClientIp(server, req, proto);
|
|
2626
|
-
const priorXff = req.headers.get("x-forwarded-for");
|
|
2627
|
-
const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
|
|
2628
|
-
if (xff) fwdHeaders.set("x-forwarded-for", xff);
|
|
2629
|
-
fwdHeaders.set("x-forwarded-proto", proto);
|
|
2630
|
-
const publicHost = req.headers.get("host");
|
|
2631
|
-
if (publicHost) fwdHeaders.set("x-forwarded-host", publicHost);
|
|
2632
|
-
// Override Host so the upstream sees its own service-net name, not
|
|
2633
|
-
// the public hostname. Lets origin servers that vhost by Host header
|
|
2634
|
-
// continue to find the right virtual host.
|
|
2635
|
-
fwdHeaders.set("host", `${service}:${port}`);
|
|
2616
|
+
const fwdHeaders = upstreamRequestHeaders(req.headers, {
|
|
2617
|
+
proto,
|
|
2618
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2619
|
+
});
|
|
2636
2620
|
// Fresh connection per upstream request — never reuse a pooled
|
|
2637
2621
|
// keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
|
|
2638
2622
|
// to 5s) close pooled connections under Bun's fetch, and the next
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
forwardableHeaders,
|
|
7
7
|
isCorsPreflight,
|
|
8
8
|
isHopByHop,
|
|
9
|
+
upstreamRequestHeaders,
|
|
9
10
|
} from "./http-proxy";
|
|
10
11
|
|
|
11
12
|
describe("hop-by-hop headers", () => {
|
|
@@ -13,7 +14,7 @@ describe("hop-by-hop headers", () => {
|
|
|
13
14
|
expect(isHopByHop("Connection")).toBe(true);
|
|
14
15
|
expect(isHopByHop("TRANSFER-ENCODING")).toBe(true);
|
|
15
16
|
expect(isHopByHop("upgrade")).toBe(true);
|
|
16
|
-
expect(isHopByHop("host")).toBe(
|
|
17
|
+
expect(isHopByHop("host")).toBe(false);
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
test("ordinary headers are forwardable", () => {
|
|
@@ -23,8 +24,7 @@ describe("hop-by-hop headers", () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
/** Forwarding transfer-encoding from a re-encoded body produces a
|
|
26
|
-
* response the client cannot frame
|
|
27
|
-
* service the upstream actually is. */
|
|
27
|
+
* response the client cannot frame. Host identifies the requested origin. */
|
|
28
28
|
test("forwardableHeaders drops them and keeps the rest", () => {
|
|
29
29
|
const h = new Headers({
|
|
30
30
|
host: "app.test",
|
|
@@ -34,7 +34,7 @@ describe("hop-by-hop headers", () => {
|
|
|
34
34
|
"content-type": "application/json",
|
|
35
35
|
});
|
|
36
36
|
const out = forwardableHeaders(h);
|
|
37
|
-
expect(out.get("host")).
|
|
37
|
+
expect(out.get("host")).toBe("app.test");
|
|
38
38
|
expect(out.get("connection")).toBeNull();
|
|
39
39
|
expect(out.get("transfer-encoding")).toBeNull();
|
|
40
40
|
expect(out.get("authorization")).toBe("Bearer x");
|
|
@@ -49,6 +49,68 @@ describe("hop-by-hop headers", () => {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
describe("upstream request headers", () => {
|
|
53
|
+
test("preserves authority and stamps forwarding headers from the incoming request", () => {
|
|
54
|
+
const headers = upstreamRequestHeaders(new Headers({
|
|
55
|
+
host: "bucket.storage.test:8443",
|
|
56
|
+
"x-forwarded-host": "unrelated.test",
|
|
57
|
+
"x-forwarded-proto": "http",
|
|
58
|
+
"x-forwarded-for": "10.0.0.1",
|
|
59
|
+
authorization: "signed-request",
|
|
60
|
+
}), { proto: "https", clientIp: "10.0.0.2" });
|
|
61
|
+
expect(headers.get("host")).toBe("bucket.storage.test:8443");
|
|
62
|
+
expect(headers.get("x-forwarded-host")).toBe("bucket.storage.test:8443");
|
|
63
|
+
expect(headers.get("x-forwarded-proto")).toBe("https");
|
|
64
|
+
expect(headers.get("x-forwarded-for")).toBe("10.0.0.1, 10.0.0.2");
|
|
65
|
+
expect(headers.get("authorization")).toBe("signed-request");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test.each(["http", "https"])("%s ingress forwards Host to a separately addressed upstream", async (proto) => {
|
|
69
|
+
const upstream = Bun.serve({
|
|
70
|
+
port: 0, hostname: "127.0.0.1",
|
|
71
|
+
async fetch(req) {
|
|
72
|
+
const url = new URL(req.url);
|
|
73
|
+
return Response.json({
|
|
74
|
+
host: req.headers.get("host"),
|
|
75
|
+
forwardedHost: req.headers.get("x-forwarded-host"),
|
|
76
|
+
forwardedProto: req.headers.get("x-forwarded-proto"),
|
|
77
|
+
authorization: req.headers.get("authorization"),
|
|
78
|
+
method: req.method, path: url.pathname + url.search, body: await req.text(),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Both listeners in the daemon speak HTTP; HTTPS reaches one through the
|
|
83
|
+
// TLS terminator, with proto supplied explicitly to the forwarding path.
|
|
84
|
+
const proxy = Bun.serve({
|
|
85
|
+
port: 0, hostname: "127.0.0.1",
|
|
86
|
+
fetch(req) {
|
|
87
|
+
const url = new URL(req.url);
|
|
88
|
+
const headers = upstreamRequestHeaders(req.headers, { proto });
|
|
89
|
+
return fetch(new Request(new URL(url.pathname + url.search, upstream.url), {
|
|
90
|
+
method: req.method, headers, body: req.body, redirect: "manual",
|
|
91
|
+
}));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
for (const host of ["storage.test", "bucket.storage.test:8443"]) {
|
|
96
|
+
const res = await fetch(new URL("/uploads/file?generation=7", proxy.url), {
|
|
97
|
+
method: "PUT", body: "contents",
|
|
98
|
+
headers: { host, authorization: "signed-request" },
|
|
99
|
+
});
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(await res.json()).toEqual({
|
|
102
|
+
host, forwardedHost: host, forwardedProto: proto,
|
|
103
|
+
authorization: "signed-request", method: "PUT",
|
|
104
|
+
path: "/uploads/file?generation=7", body: "contents",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
proxy.stop(true);
|
|
109
|
+
upstream.stop(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
52
114
|
describe("preflight detection", () => {
|
|
53
115
|
const req = (method: string, headers: Record<string, string>) =>
|
|
54
116
|
new Request("http://app.test/", { method, headers });
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
* Forwarding `transfer-encoding` or `content-length` from a re-encoded
|
|
14
14
|
* body is the classic way to produce a response the client cannot frame;
|
|
15
15
|
* forwarding `connection` leaks our keep-alive intent to the upstream.
|
|
16
|
-
* `host`
|
|
17
|
-
* the service it actually is, not the public name the client asked for.
|
|
16
|
+
* `host` identifies the requested origin and must survive the proxy hop.
|
|
18
17
|
*/
|
|
19
18
|
export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
|
|
20
19
|
"connection",
|
|
@@ -25,7 +24,6 @@ export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
|
|
|
25
24
|
"trailers",
|
|
26
25
|
"transfer-encoding",
|
|
27
26
|
"upgrade",
|
|
28
|
-
"host",
|
|
29
27
|
]);
|
|
30
28
|
|
|
31
29
|
export function isHopByHop(name: string): boolean {
|
|
@@ -41,6 +39,24 @@ export function forwardableHeaders(headers: Headers): Headers {
|
|
|
41
39
|
return out;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
/** Preserve the requested Host (including its port) while dialing the service's
|
|
43
|
+
* internal address. HTTP and WebSocket forwarding share this policy. */
|
|
44
|
+
export function upstreamRequestHeaders(
|
|
45
|
+
headers: Headers,
|
|
46
|
+
opts: { proto: string; clientIp?: string },
|
|
47
|
+
): Headers {
|
|
48
|
+
const out = forwardableHeaders(headers);
|
|
49
|
+
const priorXff = headers.get("x-forwarded-for");
|
|
50
|
+
const xff = opts.clientIp
|
|
51
|
+
? priorXff ? `${priorXff}, ${opts.clientIp}` : opts.clientIp
|
|
52
|
+
: priorXff;
|
|
53
|
+
if (xff) out.set("x-forwarded-for", xff);
|
|
54
|
+
out.set("x-forwarded-proto", opts.proto);
|
|
55
|
+
const host = headers.get("host");
|
|
56
|
+
if (host) out.set("x-forwarded-host", host);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* A CORS preflight is an `OPTIONS` carrying both `Origin` and
|
|
46
62
|
* `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
|
|
@@ -20,7 +20,7 @@ describe("parseSubprotocols", () => {
|
|
|
20
20
|
describe("upstreamHandshakeHeaders", () => {
|
|
21
21
|
test("forwards cookies and origin, drops the client's handshake fields, stamps provenance", () => {
|
|
22
22
|
const req = new Headers({
|
|
23
|
-
host: "api.prod.specific.dev",
|
|
23
|
+
host: "api.prod.specific.dev:8443",
|
|
24
24
|
connection: "Upgrade",
|
|
25
25
|
upgrade: "websocket",
|
|
26
26
|
"sec-websocket-key": "abc==",
|
|
@@ -32,16 +32,14 @@ describe("upstreamHandshakeHeaders", () => {
|
|
|
32
32
|
authorization: "Bearer t",
|
|
33
33
|
});
|
|
34
34
|
const out = upstreamHandshakeHeaders(req, {
|
|
35
|
-
service: "api",
|
|
36
|
-
port: 4000,
|
|
37
35
|
proto: "https",
|
|
38
36
|
clientIp: "10.0.0.7",
|
|
39
37
|
});
|
|
40
38
|
expect(out["cookie"]).toBe("sid=1");
|
|
41
39
|
expect(out["origin"]).toBe("https://app.test");
|
|
42
40
|
expect(out["authorization"]).toBe("Bearer t");
|
|
43
|
-
expect(out["host"]).toBe("api:
|
|
44
|
-
expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev");
|
|
41
|
+
expect(out["host"]).toBe("api.prod.specific.dev:8443");
|
|
42
|
+
expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev:8443");
|
|
45
43
|
expect(out["x-forwarded-proto"]).toBe("https");
|
|
46
44
|
expect(out["x-forwarded-for"]).toBe("10.0.0.7");
|
|
47
45
|
for (const k of Object.keys(out)) {
|
|
@@ -52,8 +50,6 @@ describe("upstreamHandshakeHeaders", () => {
|
|
|
52
50
|
|
|
53
51
|
test("appends the client to an existing x-forwarded-for", () => {
|
|
54
52
|
const out = upstreamHandshakeHeaders(new Headers({ "x-forwarded-for": "1.2.3.4" }), {
|
|
55
|
-
service: "api",
|
|
56
|
-
port: 1,
|
|
57
53
|
proto: "http",
|
|
58
54
|
clientIp: "10.0.0.7",
|
|
59
55
|
});
|
|
@@ -71,7 +67,7 @@ describe("the bridge on loopback", () => {
|
|
|
71
67
|
let upstream: ReturnType<typeof Bun.serve>;
|
|
72
68
|
let bridge: ReturnType<typeof Bun.serve>;
|
|
73
69
|
/** What the upstream saw on its last handshake. */
|
|
74
|
-
let seen: { protocols: string[]; cookie: string | null; xfh: string | null } | null = null;
|
|
70
|
+
let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
|
|
75
71
|
|
|
76
72
|
beforeAll(() => {
|
|
77
73
|
upstream = Bun.serve({
|
|
@@ -81,6 +77,7 @@ describe("the bridge on loopback", () => {
|
|
|
81
77
|
seen = {
|
|
82
78
|
protocols,
|
|
83
79
|
cookie: req.headers.get("cookie"),
|
|
80
|
+
host: req.headers.get("host"),
|
|
84
81
|
xfh: req.headers.get("x-forwarded-host"),
|
|
85
82
|
};
|
|
86
83
|
if (protocols[1] !== "ticket-ok") return new Response("Unauthorized", { status: 401 });
|
|
@@ -110,8 +107,6 @@ describe("the bridge on loopback", () => {
|
|
|
110
107
|
async fetch(req, server) {
|
|
111
108
|
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
112
109
|
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
113
|
-
service: "api",
|
|
114
|
-
port: upstream.port!,
|
|
115
110
|
proto: "https",
|
|
116
111
|
});
|
|
117
112
|
const url = new URL(req.url);
|
|
@@ -153,7 +148,7 @@ describe("the bridge on loopback", () => {
|
|
|
153
148
|
) => WebSocket;
|
|
154
149
|
const ws = new Ctor(`ws://127.0.0.1:${bridge.port}/console?x=1`, {
|
|
155
150
|
protocols,
|
|
156
|
-
headers: { cookie: "sid=abc", host: "api.prod.specific.dev" },
|
|
151
|
+
headers: { cookie: "sid=abc", host: "api.prod.specific.dev:8443" },
|
|
157
152
|
});
|
|
158
153
|
ws.binaryType = "arraybuffer";
|
|
159
154
|
const events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }> = [];
|
|
@@ -180,7 +175,8 @@ describe("the bridge on loopback", () => {
|
|
|
180
175
|
expect(seen).toEqual({
|
|
181
176
|
protocols: ["console", "ticket-ok"],
|
|
182
177
|
cookie: "sid=abc",
|
|
183
|
-
|
|
178
|
+
host: "api.prod.specific.dev:8443",
|
|
179
|
+
xfh: "api.prod.specific.dev:8443",
|
|
184
180
|
});
|
|
185
181
|
await Bun.sleep(20);
|
|
186
182
|
ws.send("hi");
|
package/src/harness/ws-bridge.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* without a VM.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { upstreamRequestHeaders } from "./http-proxy.js";
|
|
24
24
|
|
|
25
25
|
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
26
26
|
* WebSocket client mints its own key and version, and the protocol list
|
|
@@ -56,33 +56,20 @@ export function parseSubprotocols(header: string | null | undefined): string[] {
|
|
|
56
56
|
* The headers the upstream handshake carries: everything forwardable from
|
|
57
57
|
* the client's request (cookies, authorization, origin, user-agent …),
|
|
58
58
|
* minus the client's own handshake fields, plus the proxy provenance
|
|
59
|
-
* headers and
|
|
59
|
+
* headers and the original Host.
|
|
60
60
|
*/
|
|
61
61
|
export function upstreamHandshakeHeaders(
|
|
62
62
|
req: Headers,
|
|
63
63
|
opts: {
|
|
64
|
-
service: string;
|
|
65
|
-
port: number;
|
|
66
64
|
proto: string;
|
|
67
65
|
clientIp?: string;
|
|
68
66
|
},
|
|
69
67
|
): Record<string, string> {
|
|
70
68
|
const out: Record<string, string> = {};
|
|
71
|
-
|
|
69
|
+
upstreamRequestHeaders(req, opts).forEach((value, key) => {
|
|
72
70
|
if (HANDSHAKE_HEADERS.has(key)) return;
|
|
73
71
|
out[key] = value;
|
|
74
72
|
});
|
|
75
|
-
const priorXff = req.get("x-forwarded-for");
|
|
76
|
-
const xff = opts.clientIp
|
|
77
|
-
? priorXff
|
|
78
|
-
? `${priorXff}, ${opts.clientIp}`
|
|
79
|
-
: opts.clientIp
|
|
80
|
-
: priorXff;
|
|
81
|
-
if (xff) out["x-forwarded-for"] = xff;
|
|
82
|
-
out["x-forwarded-proto"] = opts.proto;
|
|
83
|
-
const publicHost = req.get("host");
|
|
84
|
-
if (publicHost) out["x-forwarded-host"] = publicHost;
|
|
85
|
-
out["host"] = `${opts.service}:${opts.port}`;
|
|
86
73
|
return out;
|
|
87
74
|
}
|
|
88
75
|
|