|
| 1 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 2 | +import { handleAnalyticsProxy } from "./analytics-proxy"; |
| 3 | + |
| 4 | +// #8387: the analytics proxy is the cookieless-beacon relay to the Umami-compatible upstream. Its four |
| 5 | +// security behaviors (strict allowlist, cookie strip, cf-connecting-ip-only x-forwarded-for, set-cookie |
| 6 | +// strip) had zero coverage. These pin each one against a stubbed upstream fetch. |
| 7 | + |
| 8 | +const UPSTREAM = "https://tasty.aethereal.dev"; |
| 9 | + |
| 10 | +type ForwardedCall = { url: string; method: string; headers: Headers }; |
| 11 | + |
| 12 | +/** Stub global fetch to return `response`, recording each forwarded request in a typed, inspectable list. */ |
| 13 | +function stubUpstream(response: Response) { |
| 14 | + const calls: ForwardedCall[] = []; |
| 15 | + const fetchMock = vi.fn( |
| 16 | + async (url: string | URL, init?: { method?: string; headers?: HeadersInit }) => { |
| 17 | + calls.push({ |
| 18 | + url: String(url), |
| 19 | + method: init?.method ?? "GET", |
| 20 | + headers: new Headers(init?.headers), |
| 21 | + }); |
| 22 | + return response; |
| 23 | + }, |
| 24 | + ); |
| 25 | + vi.stubGlobal("fetch", fetchMock); |
| 26 | + return { fetchMock, calls }; |
| 27 | +} |
| 28 | + |
| 29 | +function send(init: RequestInit & { path?: string; query?: string } = {}) { |
| 30 | + const { path = "/stats/api/send", query = "", ...rest } = init; |
| 31 | + return new Request(`https://loopover.ai${path}${query}`, { method: "POST", ...rest }); |
| 32 | +} |
| 33 | + |
| 34 | +afterEach(() => { |
| 35 | + vi.unstubAllGlobals(); |
| 36 | +}); |
| 37 | + |
| 38 | +describe("handleAnalyticsProxy", () => { |
| 39 | + it("forwards an allowed POST to the upstream collect endpoint, preserving the query and relaying the response", async () => { |
| 40 | + const { fetchMock, calls } = stubUpstream( |
| 41 | + new Response("ok-body", { status: 202, statusText: "Accepted" }), |
| 42 | + ); |
| 43 | + |
| 44 | + const response = await handleAnalyticsProxy( |
| 45 | + send({ query: "?v=2&cache=abc", body: "beacon-payload" }), |
| 46 | + ); |
| 47 | + |
| 48 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 49 | + // /stats prefix stripped, path + query preserved onto the real upstream host. |
| 50 | + expect(calls[0]!.url).toBe(`${UPSTREAM}/api/send?v=2&cache=abc`); |
| 51 | + expect(calls[0]!.method).toBe("POST"); |
| 52 | + // Upstream status/statusText/body are relayed back untouched. |
| 53 | + expect(response).toBeInstanceOf(Response); |
| 54 | + expect(response!.status).toBe(202); |
| 55 | + expect(response!.statusText).toBe("Accepted"); |
| 56 | + expect(await response!.text()).toBe("ok-body"); |
| 57 | + }); |
| 58 | + |
| 59 | + it("rejects a disallowed method with 405 + an allow header, without ever calling fetch (method gate)", async () => { |
| 60 | + const { fetchMock } = stubUpstream(new Response("should not be used")); |
| 61 | + |
| 62 | + const response = await handleAnalyticsProxy(send({ method: "GET" })); |
| 63 | + |
| 64 | + expect(response!.status).toBe(405); |
| 65 | + expect(response!.headers.get("allow")).toBe("POST"); |
| 66 | + expect(fetchMock).not.toHaveBeenCalled(); |
| 67 | + }); |
| 68 | + |
| 69 | + it("returns undefined (falls through to SSR) and never fetches for a path outside the allowlist", async () => { |
| 70 | + const { fetchMock } = stubUpstream(new Response("should not be used")); |
| 71 | + |
| 72 | + // The admin/auth API lives on the same upstream origin as the collect endpoint -- must NOT be proxied. |
| 73 | + expect(await handleAnalyticsProxy(send({ path: "/stats/admin" }))).toBeUndefined(); |
| 74 | + expect(await handleAnalyticsProxy(send({ path: "/stats/api/collect" }))).toBeUndefined(); |
| 75 | + expect(fetchMock).not.toHaveBeenCalled(); |
| 76 | + }); |
| 77 | + |
| 78 | + it("strips the visitor's first-party cookie before forwarding (cookieless guarantee, #597)", async () => { |
| 79 | + const { calls } = stubUpstream(new Response(null, { status: 200 })); |
| 80 | + |
| 81 | + await handleAnalyticsProxy( |
| 82 | + send({ headers: { cookie: "session=secret; theme=dark", "x-keep": "yes" } }), |
| 83 | + ); |
| 84 | + |
| 85 | + expect(calls[0]!.headers.get("cookie")).toBeNull(); |
| 86 | + // Non-stripped headers still pass through, so this isn't just dropping everything. |
| 87 | + expect(calls[0]!.headers.get("x-keep")).toBe("yes"); |
| 88 | + }); |
| 89 | + |
| 90 | + it("re-derives x-forwarded-for from the trusted cf-connecting-ip and drops any client-supplied value (no geo spoofing)", async () => { |
| 91 | + const { calls } = stubUpstream(new Response(null, { status: 200 })); |
| 92 | + |
| 93 | + await handleAnalyticsProxy( |
| 94 | + send({ headers: { "cf-connecting-ip": "203.0.113.7", "x-forwarded-for": "66.66.66.66" } }), |
| 95 | + ); |
| 96 | + |
| 97 | + expect(calls[0]!.headers.get("x-forwarded-for")).toBe("203.0.113.7"); |
| 98 | + // The trusted-IP header itself is not leaked upstream. |
| 99 | + expect(calls[0]!.headers.get("cf-connecting-ip")).toBeNull(); |
| 100 | + }); |
| 101 | + |
| 102 | + it("does not set x-forwarded-for when there is no cf-connecting-ip", async () => { |
| 103 | + const { calls } = stubUpstream(new Response(null, { status: 200 })); |
| 104 | + |
| 105 | + await handleAnalyticsProxy(send({ headers: { "x-forwarded-for": "66.66.66.66" } })); |
| 106 | + |
| 107 | + expect(calls[0]!.headers.get("x-forwarded-for")).toBeNull(); |
| 108 | + }); |
| 109 | + |
| 110 | + it("strips set-cookie from the upstream response before relaying it to the browser", async () => { |
| 111 | + stubUpstream( |
| 112 | + new Response("ok", { |
| 113 | + status: 200, |
| 114 | + headers: { "set-cookie": "umami=1; Path=/", "x-app": "v1" }, |
| 115 | + }), |
| 116 | + ); |
| 117 | + |
| 118 | + const response = await handleAnalyticsProxy(send()); |
| 119 | + |
| 120 | + expect(response!.headers.get("set-cookie")).toBeNull(); |
| 121 | + expect(response!.headers.get("x-app")).toBe("v1"); // unrelated response headers are still relayed |
| 122 | + }); |
| 123 | + |
| 124 | + it("fails quietly with 502 when the upstream fetch throws (analytics must never take the page down)", async () => { |
| 125 | + vi.stubGlobal( |
| 126 | + "fetch", |
| 127 | + vi.fn(async () => { |
| 128 | + throw new Error("network down"); |
| 129 | + }), |
| 130 | + ); |
| 131 | + |
| 132 | + const response = await handleAnalyticsProxy(send({ body: "beacon" })); |
| 133 | + |
| 134 | + expect(response!.status).toBe(502); |
| 135 | + }); |
| 136 | +}); |
0 commit comments