diff --git a/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts index ee69b118c..215b24ce3 100644 --- a/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts +++ b/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts @@ -2,21 +2,23 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; +import { createOnError, guardTRPCSource } from "@/lib/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { edgeRouter } from "@openstatus/api/src/edge"; export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/edge", router: edgeRouter, req: req, createContext: () => createTRPCContext({ req, auth }), - onError: ({ error }) => { - console.log("Error in tRPC handler (edge)"); - console.error(error); - }, + onError: createOnError("edge"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts index 1352dcf0c..de648a3b4 100644 --- a/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts +++ b/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts @@ -2,22 +2,24 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; +import { createOnError, guardTRPCSource } from "@/lib/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { lambdaRouter } from "@openstatus/api/src/lambda"; // Stripe is incompatible with Edge runtimes due to using Node.js events // export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/lambda", router: lambdaRouter, req: req, createContext: () => createTRPCContext({ req, auth }), - onError: ({ error }) => { - console.log("Error in tRPC handler (lambda)"); - console.error(error); - }, + onError: createOnError("lambda"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/lib/trpc/shared.ts b/apps/dashboard/src/lib/trpc/shared.ts index 23af94147..4dd4694df 100644 --- a/apps/dashboard/src/lib/trpc/shared.ts +++ b/apps/dashboard/src/lib/trpc/shared.ts @@ -4,6 +4,30 @@ import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@openstatus/api"; import superjson from "superjson"; +/** + * Shared onError handler for tRPC route handlers. + */ +export function createOnError(label: string) { + return ({ error }: { error: { code: string; message: string } }) => { + console.log(`Error in tRPC handler (${label})`); + console.error(error); + }; +} + +/** + * Filter out requests that don't come from our tRPC clients. + * Our server and client links always set `x-trpc-source`. + * This is a convention filter for bots/crawlers, not a security boundary — + * the header is trivially spoofable. Auth is enforced by protectedProcedure. + */ +export function guardTRPCSource(req: Request): Response | null { + const source = req.headers.get("x-trpc-source"); + if (source !== "server" && source !== "client") { + return new Response(null, { status: 401 }); + } + return null; +} + const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // Note: dashboard has its own tRPC API routes diff --git a/apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts b/apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts index cf4f2382b..215b24ce3 100644 --- a/apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts +++ b/apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts @@ -2,19 +2,23 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; -import { createOnError } from "@/lib/trpc/shared"; +import { createOnError, guardTRPCSource } from "@/lib/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { edgeRouter } from "@openstatus/api/src/edge"; export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/edge", router: edgeRouter, req: req, createContext: () => createTRPCContext({ req, auth }), - onError: createOnError(req, "edge"), + onError: createOnError("edge"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts b/apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts index 416a78262..de648a3b4 100644 --- a/apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts +++ b/apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts @@ -2,20 +2,24 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; -import { createOnError } from "@/lib/trpc/shared"; +import { createOnError, guardTRPCSource } from "@/lib/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { lambdaRouter } from "@openstatus/api/src/lambda"; // Stripe is incompatible with Edge runtimes due to using Node.js events // export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/lambda", router: lambdaRouter, req: req, createContext: () => createTRPCContext({ req, auth }), - onError: createOnError(req, "lambda"), + onError: createOnError("lambda"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/status-page/src/lib/trpc/shared.ts b/apps/status-page/src/lib/trpc/shared.ts index c5dd92c2f..16625522e 100644 --- a/apps/status-page/src/lib/trpc/shared.ts +++ b/apps/status-page/src/lib/trpc/shared.ts @@ -7,22 +7,28 @@ import superjson from "superjson"; /** * Shared onError handler for tRPC route handlers. - * Ignores input validation errors from external traffic (bots/crawlers) - * hitting the tRPC endpoint with malformed batch URLs. - * Our tRPC clients always set `x-trpc-source`; its absence means the - * request didn't come from our app. Only suppresses BAD_REQUEST so that - * other error types (UNAUTHORIZED, INTERNAL_SERVER_ERROR, etc.) are - * still logged regardless of source. */ -export function createOnError(req: Request, label: string) { +export function createOnError(label: string) { return ({ error }: { error: TRPCError }) => { - if (error.code === "BAD_REQUEST" && !req.headers.get("x-trpc-source")) - return; console.log(`Error in tRPC handler (${label})`); console.error(error); }; } +/** + * Filter out requests that don't come from our tRPC clients. + * Our server and client links always set `x-trpc-source`. + * This is a convention filter for bots/crawlers, not a security boundary — + * the header is trivially spoofable. Auth is enforced by protectedProcedure. + */ +export function guardTRPCSource(req: Request): Response | null { + const source = req.headers.get("x-trpc-source"); + if (source !== "server" && source !== "client") { + return new Response(null, { status: 401 }); + } + return null; +} + const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // Note: status-page has its own tRPC API routes diff --git a/apps/web/src/app/api/trpc/edge/[trpc]/route.ts b/apps/web/src/app/api/trpc/edge/[trpc]/route.ts index 2c1fc5285..884b2f623 100644 --- a/apps/web/src/app/api/trpc/edge/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/edge/[trpc]/route.ts @@ -1,21 +1,23 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; +import { createOnError, guardTRPCSource } from "@/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { edgeRouter } from "@openstatus/api/src/edge"; export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/edge", router: edgeRouter, req: req, createContext: () => createTRPCContext({ req }), - onError: ({ error }) => { - console.log("Error in tRPC handler (edge)"); - console.error(error); - }, + onError: createOnError("edge"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/web/src/app/api/trpc/lambda/[trpc]/route.ts b/apps/web/src/app/api/trpc/lambda/[trpc]/route.ts index b36568088..e53fdc4a6 100644 --- a/apps/web/src/app/api/trpc/lambda/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/lambda/[trpc]/route.ts @@ -1,22 +1,24 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; +import { createOnError, guardTRPCSource } from "@/trpc/shared"; import { createTRPCContext } from "@openstatus/api"; import { lambdaRouter } from "@openstatus/api/src/lambda"; // Stripe is incompatible with Edge runtimes due to using Node.js events // export const runtime = "edge"; -const handler = (req: NextRequest) => - fetchRequestHandler({ +const handler = (req: NextRequest) => { + const blocked = guardTRPCSource(req); + if (blocked) return blocked; + + return fetchRequestHandler({ endpoint: "/api/trpc/lambda", router: lambdaRouter, req: req, createContext: () => createTRPCContext({ req }), - onError: ({ error }) => { - console.log("Error in tRPC handler (lambda)"); - console.error(error); - }, + onError: createOnError("lambda"), }); +}; export { handler as GET, handler as POST }; diff --git a/apps/web/src/trpc/shared.ts b/apps/web/src/trpc/shared.ts index d6c8c89c2..ddf7cdcbd 100644 --- a/apps/web/src/trpc/shared.ts +++ b/apps/web/src/trpc/shared.ts @@ -4,6 +4,30 @@ import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@openstatus/api"; import superjson from "superjson"; +/** + * Shared onError handler for tRPC route handlers. + */ +export function createOnError(label: string) { + return ({ error }: { error: { code: string; message: string } }) => { + console.log(`Error in tRPC handler (${label})`); + console.error(error); + }; +} + +/** + * Filter out requests that don't come from our tRPC clients. + * Our server and client links always set `x-trpc-source`. + * This is a convention filter for bots/crawlers, not a security boundary — + * the header is trivially spoofable. Auth is enforced by protectedProcedure. + */ +export function guardTRPCSource(req: Request): Response | null { + const source = req.headers.get("x-trpc-source"); + if (source !== "server" && source !== "client") { + return new Response(null, { status: 401 }); + } + return null; +} + const getBaseUrl = () => { if (typeof window !== "undefined") return ""; if (process.env.VERCEL_ENV === "production") diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index db7b1dc03..ba5401d6d 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -73,18 +73,6 @@ export const createTRPCContext = async (opts: { }) => { // Use provided auth function or return null session const session = opts.auth ? await opts.auth() : null; - - console.log("[createTRPCContext debug]", { - hasAuthFn: !!opts.auth, - sessionResult: session ? "exists" : "null", - hasReq: !!opts.req, - hasCookie: !!opts.req?.headers.get("cookie"), - hasSessionToken: - !!opts.req?.cookies.get("authjs.session-token")?.value || - !!opts.req?.cookies.get("__Secure-authjs.session-token")?.value, - userAgent: opts.req?.headers.get("user-agent"), - }); - const workspace = null; const user = null;