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
138 changes: 138 additions & 0 deletions packages/stack/src/HttpProxy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,144 @@ it.live("tells upstreams the client address", () =>
const headerOf = (value: string | ReadonlyArray<string> | undefined) =>
value === undefined || typeof value === "string" ? value : value.join(", ");

it.live("tells upstreams the scheme, host, port, path and stripped prefix the client reached", () =>
Effect.scoped(
Effect.gen(function* () {
const forwarded = (
headers: Readonly<Record<string, string | ReadonlyArray<string> | undefined>>,
) => ({
proto: headerOf(headers["x-forwarded-proto"]),
host: headerOf(headers["x-forwarded-host"]),
port: headerOf(headers["x-forwarded-port"]),
path: headerOf(headers["x-forwarded-path"]),
prefix: headerOf(headers["x-forwarded-prefix"]),
});
const backend = createServer((incoming, response) => {
response.end(JSON.stringify(forwarded(incoming.headers)));
});
const sockets = new WebSocketServer({ server: backend });
const upgraded = yield* Deferred.make<ReturnType<typeof forwarded>, HttpProxyTestError>();
sockets.on("connection", (socket, incoming) => {
Deferred.doneUnsafe(upgraded, Effect.succeed(forwarded(incoming.headers)));
socket.close();
});
const backendAddress = yield* listen(backend, { beforeClose: () => sockets.close() });
const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 });
const target = Effect.succeed(backendAddress);
yield* proxy.setRoutes([
{ id: "functions", prefix: "/functions/v1", upstreamPrefix: "/", gateway: true, target },
{
id: "graphql",
prefix: "/graphql/v1",
upstreamPrefix: "/rpc/graphql",
forwardedPrefix: "/graphql/v1",
gateway: true,
target,
},
{ id: "studio", prefix: "/", target },
]);
const spoofed = {
"x-forwarded-proto": "https",
"x-forwarded-host": "spoofed.example",
"x-forwarded-port": "443",
"x-forwarded-path": "/spoofed",
"x-forwarded-prefix": "/spoofed",
};
const send = (path: string, headers: Readonly<Record<string, string>>) =>
request(proxy.port, path, new Uint8Array(), headers, "GET").pipe(
Effect.provide(NodeHttpClient.layerNodeHttp),
Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))),
);
const port = String(proxy.port);

expect(yield* send("/functions/v1/hello?name=x", spoofed)).toEqual({
proto: "http",
host: "127.0.0.1",
port,
path: "/functions/v1/hello",
prefix: "/functions/v1/",
});
expect(yield* send("/functions/v1/hello", { host: "LOCALHOST.:54321" })).toEqual(
expect.objectContaining({ host: "localhost" }),
);
expect(yield* send("/graphql/v1", {})).toEqual(
expect.objectContaining({ path: "/graphql/v1", prefix: "/graphql/v1" }),
);
/* A dedicated listener's route is not Kong's, so it adds none of them. */
expect(yield* send("/project/default", { host: "Studio.Example.test:9999" })).toEqual({
proto: undefined,
host: undefined,
port: undefined,
path: undefined,
prefix: undefined,
});

const client = new WebSocket(`ws://127.0.0.1:${proxy.port}/functions/v1/socket`, {
headers: spoofed,
});
client.on("error", (cause) =>
Deferred.doneUnsafe(
upgraded,
Effect.fail(new HttpProxyTestError({ message: cause.message, cause })),
),
);
yield* Effect.addFinalizer(() => Effect.sync(() => client.terminate()));
expect(yield* Deferred.await(upgraded).pipe(Effect.timeout("10 seconds"))).toEqual({
proto: "http",
host: "127.0.0.1",
port,
path: "/functions/v1/socket",
prefix: "/functions/v1/",
});
}),
).pipe(Effect.provide(NodeServices.layer)),
);

it.live("adds a route's default headers only when the client sent none", () =>
Effect.scoped(
Effect.gen(function* () {
const backend = createServer((incoming, response) => {
response.end(
JSON.stringify({ url: incoming.url, profile: incoming.headers["content-profile"] }),
);
});
const backendAddress = yield* listen(backend);
const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 });
yield* proxy.setRoutes([
{
id: "graphql",
prefix: "/graphql/v1",
upstreamPrefix: "/rpc/graphql",
addHeaders: { "content-profile": "graphql_public" },
target: Effect.succeed(backendAddress),
},
{
id: "mixed",
prefix: "/mixed",
upstreamPrefix: "/rpc/graphql",
addHeaders: { "Content-Profile": "graphql_public" },
target: Effect.succeed(backendAddress),
},
]);
const send = (headers: Readonly<Record<string, string>>, path = "/graphql/v1") =>
request(proxy.port, path, new Uint8Array(), headers).pipe(
Effect.provide(NodeHttpClient.layerNodeHttp),
Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))),
);

expect(yield* send({})).toEqual({ url: "/rpc/graphql", profile: "graphql_public" });
expect(yield* send({ "Content-Profile": "private" })).toEqual({
url: "/rpc/graphql",
profile: "private",
});
expect(yield* send({ "content-profile": "private" }, "/mixed")).toEqual({
url: "/rpc/graphql",
profile: "private",
});
}),
).pipe(Effect.provide(NodeServices.layer)),
);

it.live("returns a gateway error naming the route and cause when a target cannot wake", () => {
const logs: Array<string> = [];
return Effect.scoped(
Expand Down
29 changes: 29 additions & 0 deletions packages/stack/src/HttpProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface HttpRoute {
readonly target: Effect.Effect<BackendAddress, ProxyError, Scope.Scope>;
readonly upstreamPrefix?: string;
readonly upstreamHost?: string;
/** Headers sent only when the client sent none, like Kong's request-transformer `add`. */
readonly addHeaders?: Readonly<Record<string, string>>;
/** X-Forwarded-Prefix of a stripped route; defaults to `prefix/`, legacy Kong's path form. */
readonly forwardedPrefix?: string;
/** A shared API listener route, which sends legacy Kong's X-Forwarded-Proto/Host/Port/Path/Prefix. */
readonly gateway?: boolean;
readonly keyRewrite?: HttpRouteKeyRewrite;
/** Sends each write over a fresh upstream connection; reads still share the pool and its retry. */
readonly freshWrites?: boolean;
Expand Down Expand Up @@ -144,6 +150,29 @@ const upstreamHeadersFor = (request: IncomingMessage, route: HttpRoute) => {
result["x-forwarded-for"] = forwardedFor === undefined ? client : `${forwardedFor}, ${client}`;
result["x-real-ip"] = client;
}
/* Like Kong without trusted IPs: client-sent X-Forwarded-* values are replaced, the host is
nginx's `$host` (lowercase, no port or trailing dot: ngx_http_validate_host, release-1.19.10
src/http/ngx_http_request.c#L2026-L2028), and only a stripped route sends its prefix. */
if (route.gateway === true) {
const host = headerValue(headers.host)
?.toLowerCase()
.replace(/\.?(?::\d*)?$/u, "");
const port = request.socket.localPort;
const prefix =
route.upstreamPrefix === undefined || route.prefix === "/"
? undefined
: (route.forwardedPrefix ?? `${route.prefix}/`);
result["x-forwarded-proto"] = "http";
if (host === undefined) delete result["x-forwarded-host"];
else result["x-forwarded-host"] = host;
if (port === undefined) delete result["x-forwarded-port"];
else result["x-forwarded-port"] = String(port);
result["x-forwarded-path"] = pathnameFor(request.url ?? "/");
if (prefix === undefined) delete result["x-forwarded-prefix"];
else result["x-forwarded-prefix"] = prefix;
}
for (const [name, value] of Object.entries(route.addHeaders ?? {}))
result[name.toLowerCase()] ??= value;
const keyRewrite = route.keyRewrite;
if (keyRewrite?.policy === "bearer") {
const value = bearerValueFor(headers, keyRewrite.keys);
Expand Down
Loading
Loading