From 30c0f8f98579981f39f723a15cb62e4d978a70f6 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 24 Jul 2026 10:12:32 -0300 Subject: [PATCH 1/2] test(ui): cover analytics-proxy's allowlist, cookie/IP, and set-cookie stripping (#8387) apps/loopover-ui/src/lib/analytics-proxy.ts relays the cookieless analytics beacon to the Umami-compatible upstream without exposing that host to the browser. Its four security behaviors -- strict path allowlist, first-party cookie stripping (the #597 fix), cf-connecting-ip-only x-forwarded-for (anti-geo-spoof), and upstream set-cookie stripping -- had zero coverage; no test referenced handleAnalyticsProxy at all. Adds apps/loopover-ui/src/lib/analytics-proxy.test.ts (vi.stubGlobal fetch, the established pattern in this dir), covering: - an allowed POST forwards to https://tasty.aethereal.dev/api/send with the query preserved and the upstream status/statusText/body relayed - a disallowed method returns 405 + allow header and never fetches - a non-allowlisted path (/stats/admin, /stats/api/collect) returns undefined (SSR fallthrough) and never fetches - the visitor cookie is stripped while unrelated headers pass through - x-forwarded-for is re-derived from cf-connecting-ip and a client-supplied value is dropped; no cf-connecting-ip means no x-forwarded-for - upstream set-cookie is stripped while other response headers are relayed - an upstream fetch throw fails quietly with 502 Test-only, no production change. --- .../src/lib/analytics-proxy.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/loopover-ui/src/lib/analytics-proxy.test.ts diff --git a/apps/loopover-ui/src/lib/analytics-proxy.test.ts b/apps/loopover-ui/src/lib/analytics-proxy.test.ts new file mode 100644 index 000000000..4f454caed --- /dev/null +++ b/apps/loopover-ui/src/lib/analytics-proxy.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleAnalyticsProxy } from "./analytics-proxy"; + +// #8387: the analytics proxy is the cookieless-beacon relay to the Umami-compatible upstream. Its four +// security behaviors (strict allowlist, cookie strip, cf-connecting-ip-only x-forwarded-for, set-cookie +// strip) had zero coverage. These pin each one against a stubbed upstream fetch. + +const UPSTREAM = "https://tasty.aethereal.dev"; + +type ForwardedCall = { url: string; method: string; headers: Headers }; + +/** Stub global fetch to return `response`, recording each forwarded request in a typed, inspectable list. */ +function stubUpstream(response: Response) { + const calls: ForwardedCall[] = []; + const fetchMock = vi.fn(async (url: string | URL, init?: { method?: string; headers?: HeadersInit }) => { + calls.push({ url: String(url), method: init?.method ?? "GET", headers: new Headers(init?.headers) }); + return response; + }); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, calls }; +} + +function send(init: RequestInit & { path?: string; query?: string } = {}) { + const { path = "/stats/api/send", query = "", ...rest } = init; + return new Request(`https://loopover.ai${path}${query}`, { method: "POST", ...rest }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("handleAnalyticsProxy", () => { + it("forwards an allowed POST to the upstream collect endpoint, preserving the query and relaying the response", async () => { + const { fetchMock, calls } = stubUpstream(new Response("ok-body", { status: 202, statusText: "Accepted" })); + + const response = await handleAnalyticsProxy(send({ query: "?v=2&cache=abc", body: "beacon-payload" })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + // /stats prefix stripped, path + query preserved onto the real upstream host. + expect(calls[0]!.url).toBe(`${UPSTREAM}/api/send?v=2&cache=abc`); + expect(calls[0]!.method).toBe("POST"); + // Upstream status/statusText/body are relayed back untouched. + expect(response).toBeInstanceOf(Response); + expect(response!.status).toBe(202); + expect(response!.statusText).toBe("Accepted"); + expect(await response!.text()).toBe("ok-body"); + }); + + it("rejects a disallowed method with 405 + an allow header, without ever calling fetch (method gate)", async () => { + const { fetchMock } = stubUpstream(new Response("should not be used")); + + const response = await handleAnalyticsProxy(send({ method: "GET" })); + + expect(response!.status).toBe(405); + expect(response!.headers.get("allow")).toBe("POST"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns undefined (falls through to SSR) and never fetches for a path outside the allowlist", async () => { + const { fetchMock } = stubUpstream(new Response("should not be used")); + + // The admin/auth API lives on the same upstream origin as the collect endpoint -- must NOT be proxied. + expect(await handleAnalyticsProxy(send({ path: "/stats/admin" }))).toBeUndefined(); + expect(await handleAnalyticsProxy(send({ path: "/stats/api/collect" }))).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("strips the visitor's first-party cookie before forwarding (cookieless guarantee, #597)", async () => { + const { calls } = stubUpstream(new Response(null, { status: 200 })); + + await handleAnalyticsProxy(send({ headers: { cookie: "session=secret; theme=dark", "x-keep": "yes" } })); + + expect(calls[0]!.headers.get("cookie")).toBeNull(); + // Non-stripped headers still pass through, so this isn't just dropping everything. + expect(calls[0]!.headers.get("x-keep")).toBe("yes"); + }); + + it("re-derives x-forwarded-for from the trusted cf-connecting-ip and drops any client-supplied value (no geo spoofing)", async () => { + const { calls } = stubUpstream(new Response(null, { status: 200 })); + + await handleAnalyticsProxy( + send({ headers: { "cf-connecting-ip": "203.0.113.7", "x-forwarded-for": "66.66.66.66" } }), + ); + + expect(calls[0]!.headers.get("x-forwarded-for")).toBe("203.0.113.7"); + // The trusted-IP header itself is not leaked upstream. + expect(calls[0]!.headers.get("cf-connecting-ip")).toBeNull(); + }); + + it("does not set x-forwarded-for when there is no cf-connecting-ip", async () => { + const { calls } = stubUpstream(new Response(null, { status: 200 })); + + await handleAnalyticsProxy(send({ headers: { "x-forwarded-for": "66.66.66.66" } })); + + expect(calls[0]!.headers.get("x-forwarded-for")).toBeNull(); + }); + + it("strips set-cookie from the upstream response before relaying it to the browser", async () => { + stubUpstream(new Response("ok", { status: 200, headers: { "set-cookie": "umami=1; Path=/", "x-app": "v1" } })); + + const response = await handleAnalyticsProxy(send()); + + expect(response!.headers.get("set-cookie")).toBeNull(); + expect(response!.headers.get("x-app")).toBe("v1"); // unrelated response headers are still relayed + }); + + it("fails quietly with 502 when the upstream fetch throws (analytics must never take the page down)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + const response = await handleAnalyticsProxy(send({ body: "beacon" })); + + expect(response!.status).toBe(502); + }); +}); From cacfaa3f1ffde9f18b1c6f0ef36548245d74cf18 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 24 Jul 2026 10:44:06 -0300 Subject: [PATCH 2/2] style(ui): apply prettier formatting to the analytics-proxy test (#8387) --- .../src/lib/analytics-proxy.test.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/loopover-ui/src/lib/analytics-proxy.test.ts b/apps/loopover-ui/src/lib/analytics-proxy.test.ts index 4f454caed..1b3fb24c2 100644 --- a/apps/loopover-ui/src/lib/analytics-proxy.test.ts +++ b/apps/loopover-ui/src/lib/analytics-proxy.test.ts @@ -12,10 +12,16 @@ type ForwardedCall = { url: string; method: string; headers: Headers }; /** Stub global fetch to return `response`, recording each forwarded request in a typed, inspectable list. */ function stubUpstream(response: Response) { const calls: ForwardedCall[] = []; - const fetchMock = vi.fn(async (url: string | URL, init?: { method?: string; headers?: HeadersInit }) => { - calls.push({ url: String(url), method: init?.method ?? "GET", headers: new Headers(init?.headers) }); - return response; - }); + const fetchMock = vi.fn( + async (url: string | URL, init?: { method?: string; headers?: HeadersInit }) => { + calls.push({ + url: String(url), + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + }); + return response; + }, + ); vi.stubGlobal("fetch", fetchMock); return { fetchMock, calls }; } @@ -31,9 +37,13 @@ afterEach(() => { describe("handleAnalyticsProxy", () => { it("forwards an allowed POST to the upstream collect endpoint, preserving the query and relaying the response", async () => { - const { fetchMock, calls } = stubUpstream(new Response("ok-body", { status: 202, statusText: "Accepted" })); + const { fetchMock, calls } = stubUpstream( + new Response("ok-body", { status: 202, statusText: "Accepted" }), + ); - const response = await handleAnalyticsProxy(send({ query: "?v=2&cache=abc", body: "beacon-payload" })); + const response = await handleAnalyticsProxy( + send({ query: "?v=2&cache=abc", body: "beacon-payload" }), + ); expect(fetchMock).toHaveBeenCalledTimes(1); // /stats prefix stripped, path + query preserved onto the real upstream host. @@ -68,7 +78,9 @@ describe("handleAnalyticsProxy", () => { it("strips the visitor's first-party cookie before forwarding (cookieless guarantee, #597)", async () => { const { calls } = stubUpstream(new Response(null, { status: 200 })); - await handleAnalyticsProxy(send({ headers: { cookie: "session=secret; theme=dark", "x-keep": "yes" } })); + await handleAnalyticsProxy( + send({ headers: { cookie: "session=secret; theme=dark", "x-keep": "yes" } }), + ); expect(calls[0]!.headers.get("cookie")).toBeNull(); // Non-stripped headers still pass through, so this isn't just dropping everything. @@ -96,7 +108,12 @@ describe("handleAnalyticsProxy", () => { }); it("strips set-cookie from the upstream response before relaying it to the browser", async () => { - stubUpstream(new Response("ok", { status: 200, headers: { "set-cookie": "umami=1; Path=/", "x-app": "v1" } })); + stubUpstream( + new Response("ok", { + status: 200, + headers: { "set-cookie": "umami=1; Path=/", "x-app": "v1" }, + }), + ); const response = await handleAnalyticsProxy(send());