From d67258855a4e04b0e5f1f9ad1e53e04d61e52c16 Mon Sep 17 00:00:00 2001 From: DADIDUNG Date: Mon, 24 Aug 2026 15:20:10 +0700 Subject: [PATCH] fix(realtime): pin auth verification origin --- apps/realtime/__tests__/auth.test.ts | 145 +++++++++++++++++++++++++++ apps/realtime/src/env.ts | 5 +- apps/realtime/src/lib/auth.ts | 11 +- 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 apps/realtime/__tests__/auth.test.ts diff --git a/apps/realtime/__tests__/auth.test.ts b/apps/realtime/__tests__/auth.test.ts new file mode 100644 index 0000000000..edce16684c --- /dev/null +++ b/apps/realtime/__tests__/auth.test.ts @@ -0,0 +1,145 @@ +import type * as Party from "partykit/server" +import { afterEach, describe, expect, test, vi } from "vitest" + +const { loggerErrorMock, loggerInfoMock, postMock } = vi.hoisted(() => ({ + loggerErrorMock: vi.fn(), + loggerInfoMock: vi.fn(), + postMock: vi.fn(), +})) + +vi.mock("ky", () => ({ + default: { + post: postMock, + }, +})) + +vi.mock("../src/logger", () => ({ + logger: { + error: loggerErrorMock, + info: loggerInfoMock, + }, +})) + +const { getAuthSession } = await import("../src/lib/auth") + +const validSession = { + user: { + name: "Ada", + email: "ada@example.com", + id: "user-1", + }, + session: { + expiresAt: "2999-01-01T00:00:00.000Z", + }, +} + +const createRequest = () => + new Request( + "https://realtime.example/workspace?token=one-time-token-secret&debug=true", + { + headers: { + authorization: "Bearer authorization-secret", + cookie: "session=cookie-secret", + origin: "https://attacker.example", + }, + }, + ) as Party.Request + +const containsSensitiveValue = ( + value: unknown, + request: Party.Request, + seenValues = new WeakSet(), +): boolean => { + if (value === request) { + return true + } + + if (typeof value === "string") { + return [ + "one-time-token-secret", + "authorization-secret", + "cookie-secret", + "?token=one-time-token-secret&debug=true", + ].some((secretValue) => value.includes(secretValue)) + } + + if (!value || typeof value !== "object") { + return false + } + + if (seenValues.has(value)) { + return false + } + seenValues.add(value) + + return Object.values(value).some((nestedValue) => + containsSensitiveValue(nestedValue, request, seenValues), + ) +} + +afterEach(() => { + postMock.mockReset() + loggerErrorMock.mockReset() + loggerInfoMock.mockReset() +}) + +describe("getAuthSession", () => { + test("verifies the token against the trusted builder URL, not request Origin", async () => { + postMock.mockReturnValueOnce({ + json: vi.fn().mockResolvedValue(validSession), + }) + + await getAuthSession(createRequest()) + + expect(postMock).toHaveBeenCalledWith( + "http://localhost:3123/api/auth/one-time-token/verify", + { json: { token: "one-time-token-secret" } }, + ) + }) + + test("does not log token, proxied request, auth headers, cookies, or query string", async () => { + const request = createRequest() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + postMock.mockReturnValueOnce({ + json: vi.fn().mockResolvedValue(validSession), + }) + + await getAuthSession(request) + + const logCalls = [ + ...loggerInfoMock.mock.calls, + ...loggerErrorMock.mock.calls, + ...consoleErrorSpy.mock.calls, + ] + + expect( + logCalls.some((call) => containsSensitiveValue(call, request)), + ).toBe(false) + }) + + test("does not log sensitive failure details", async () => { + const request = createRequest() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + postMock.mockImplementationOnce(() => { + throw new Error( + "one-time-token-secret authorization-secret cookie-secret ?token=one-time-token-secret&debug=true", + ) + }) + + await expect(getAuthSession(request)).rejects.toThrow( + "Failed to authenticate user", + ) + + const logCalls = [ + ...loggerInfoMock.mock.calls, + ...loggerErrorMock.mock.calls, + ...consoleErrorSpy.mock.calls, + ] + + expect( + logCalls.some((call) => containsSensitiveValue(call, request)), + ).toBe(false) + }) +}) diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts index 56afb5d23b..5eed866928 100644 --- a/apps/realtime/src/env.ts +++ b/apps/realtime/src/env.ts @@ -1,8 +1,11 @@ import { keys as partysocketKeys } from "@chatbotx.io/partysocket-config/keys" import { createEnv } from "@t3-oss/env-core" +import { z } from "zod" export const env = createEnv({ extends: [partysocketKeys()], - server: {}, + server: { + NEXT_PUBLIC_BUILDER_URL: z.url().default("http://localhost:3123"), + }, runtimeEnv: process.env, }) diff --git a/apps/realtime/src/lib/auth.ts b/apps/realtime/src/lib/auth.ts index e4e479f234..f97d6f3f8b 100644 --- a/apps/realtime/src/lib/auth.ts +++ b/apps/realtime/src/lib/auth.ts @@ -1,5 +1,6 @@ import ky from "ky" import type * as Party from "partykit/server" +import { env } from "../env" import { logger } from "../logger" export type Session = { @@ -25,18 +26,14 @@ export const getAuthSession = async ( proxiedRequest: Party.Request, ): Promise => { const url = new URL(proxiedRequest.url) - logger.info({ proxiedRequest }, "proxiedRequest") const token = url.searchParams.get("token") if (!token) { throw new Error("No token provided") } - const headers = proxiedRequest.headers - const origin = headers.get("origin") ?? "https://example.com" - logger.info({ origin, token }, "origin") const verificationUrl = new URL( "/api/auth/one-time-token/verify", - origin, + env.NEXT_PUBLIC_BUILDER_URL, ).toString() try { @@ -51,8 +48,8 @@ export const getAuthSession = async ( if (session && isSessionValid(session)) { return session } - } catch (error) { - console.error("Failed to authenticate user", error) + } catch { + logger.error({}, "Failed to authenticate user") throw new Error("Failed to authenticate user") }