Skip to content
Closed
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
21 changes: 14 additions & 7 deletions apps/gittensory-ui/src/lib/analytics-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ const ROUTES: Record<string, ReadonlySet<string>> = {
"/stats/api/send": new Set(["POST"]),
};

// Request headers we never forward upstream (hop-by-hop or our-origin specific).
// Request headers we never forward upstream (hop-by-hop, credentials, or our-origin specific).
const STRIP_REQUEST_HEADERS = new Set([
"host",
"authorization",
"cookie",
"proxy-authorization",
"connection",
"keep-alive",
"transfer-encoding",
Expand Down Expand Up @@ -81,14 +84,18 @@ export async function handleAnalyticsProxy(request: Request): Promise<Response |

const hasBody = request.method !== "GET" && request.method !== "HEAD";

const upstreamRequest: RequestInit = {
method: request.method,
headers,
};
if (hasBody) {
// Buffer the (tiny) collect payload so we don't need a streaming/duplex body.
upstreamRequest.body = await request.arrayBuffer();
}

let upstream: Response;
try {
upstream = await fetch(upstreamUrl, {
method: request.method,
headers,
// Buffer the (tiny) collect payload so we don't need a streaming/duplex body.
body: hasBody ? await request.arrayBuffer() : undefined,
});
upstream = await fetch(upstreamUrl, upstreamRequest);
} catch {
// Analytics must never take the page down — fail quietly.
return new Response(null, { status: 502 });
Expand Down
49 changes: 49 additions & 0 deletions test/unit/analytics-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { handleAnalyticsProxy } from "../../apps/gittensory-ui/src/lib/analytics-proxy";

describe("analytics proxy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("does not forward first-party credentials to the analytics upstream", async () => {
const fetchMock = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response("ok"),
);
vi.stubGlobal("fetch", fetchMock);

const response = await handleAnalyticsProxy(
new Request("https://gittensory.aethereal.dev/stats/api/send", {
method: "POST",
headers: {
accept: "application/json",
authorization: "Bearer ui-token",
cookie: "session=secret",
"cf-connecting-ip": "203.0.113.10",
"proxy-authorization": "Basic proxy-secret",
"x-forwarded-for": "198.51.100.25",
},
body: JSON.stringify({ type: "event" }),
}),
);

expect(response?.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();

const call = fetchMock.mock.calls[0];
expect(call).toBeDefined();
const init = call?.[1];
expect(init).toBeDefined();
const forwardedHeaders = new Headers(init?.headers);

expect(forwardedHeaders.get("accept")).toBe("application/json");
expect(forwardedHeaders.get("x-forwarded-for")).toBe(
"198.51.100.25, 203.0.113.10",
);
expect(forwardedHeaders.has("authorization")).toBe(false);
expect(forwardedHeaders.has("cookie")).toBe(false);
expect(forwardedHeaders.has("proxy-authorization")).toBe(false);
});
});
Loading