diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx index 409119b4..fb4b21d5 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx @@ -16,8 +16,6 @@ import { FormSlug } from "@/components/forms/settings/form-slug"; import { FormWorkspace } from "@/components/forms/settings/form-workspace"; import { useTRPC } from "@/lib/trpc/client"; -const BASE_URL = "https://app.openstatus.dev/invite"; - export default function Page() { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -40,7 +38,7 @@ export default function Page() { const createInvitationMutation = useMutation( trpc.invitation.create.mutationOptions({ onSuccess: (data) => { - sendInvitationMutation.mutate({ id: data.id, baseUrl: BASE_URL }); + sendInvitationMutation.mutate({ id: data.id }); queryClient.invalidateQueries({ queryKey: trpc.invitation.list.queryKey(), }); diff --git a/apps/server/src/libs/cache-keys.ts b/apps/server/src/libs/cache-keys.ts new file mode 100644 index 00000000..27086569 --- /dev/null +++ b/apps/server/src/libs/cache-keys.ts @@ -0,0 +1,6 @@ +// The Redis keyspace is flat and shared across apps — never key on bare +// user input, or one route can read or overwrite another's entries. +export const cacheKeys = { + pageStatus: (slug: string) => `status:page:${slug}`, + monitorDailyStats: (id: string | number) => `stats:monitor:${id}:daily`, +}; diff --git a/apps/server/src/routes/public/status.test.ts b/apps/server/src/routes/public/status.test.ts index 679c461e..87956190 100644 --- a/apps/server/src/routes/public/status.test.ts +++ b/apps/server/src/routes/public/status.test.ts @@ -514,3 +514,66 @@ describe("Status Route: Cache functionality", () => { await db.delete(page).where(eq(page.id, cachePage.id)); }); }); + +describe("Status Route: cache key isolation", () => { + test("never serves a foreign redis key as a status", async () => { + for (const key of ["1-daily-stats", "telegram:workspace_token:1"]) { + testRedisStore?.set(key, JSON.stringify("leaked")); + const res = await app.request( + `/public/status/${encodeURIComponent(key)}`, + ); + expect(await res.json()).toEqual({ status: "unknown" }); + } + }); + + test("a page slug shaped like a stats key leaves that key untouched", async () => { + const slug = "987654-daily-stats"; + await db.delete(page).where(eq(page.slug, slug)); + const collidingPage = await db + .insert(page) + .values({ + workspaceId: 1, + title: "Collision Test Page", + description: "", + slug, + customDomain: "", + accessType: "public", + }) + .returning() + .get(); + + const statsKey = "stats:monitor:987654:daily"; + testRedisStore?.set(statsKey, "cached-stats"); + + const res = await app.request(`/public/status/${slug}`); + expect((await res.json()).status).toBe("operational"); + expect(testRedisStore?.has(slug)).toBe(false); + expect(testRedisStore?.get(statsKey)).toBe("cached-stats"); + + await db.delete(page).where(eq(page.id, collidingPage.id)); + }); + + test("a protected page is never written to the cache", async () => { + const slug = `${TEST_PREFIX}-uncached-private`; + await db.delete(page).where(eq(page.slug, slug)); + const privatePage = await db + .insert(page) + .values({ + workspaceId: 1, + title: "Uncached Private Page", + description: "", + slug, + customDomain: "", + accessType: "password", + password: "secret", + }) + .returning() + .get(); + + const res = await app.request(`/public/status/${slug}`); + expect(await res.json()).toEqual({ status: "unknown" }); + expect(testRedisStore?.has(`status:page:${slug}`)).toBe(false); + + await db.delete(page).where(eq(page.id, privatePage.id)); + }); +}); diff --git a/apps/server/src/routes/public/status.ts b/apps/server/src/routes/public/status.ts index dc115266..7822b246 100644 --- a/apps/server/src/routes/public/status.ts +++ b/apps/server/src/routes/public/status.ts @@ -7,6 +7,7 @@ import { endTime, setMetric, startTime } from "hono/timing"; const logger = getLogger("api-server"); import { Status, Tracker } from "@openstatus/tracker"; +import { cacheKeys } from "../../libs/cache-keys"; import { redis } from "../../libs/clients"; // TODO: include ratelimiting @@ -17,7 +18,9 @@ status.get("/:slug", async (c) => { try { const { slug } = c.req.param(); - const cache = await redis.get(slug); + // Only public pages are ever written under this prefix, so a hit needs no + // access check; a page made private can stay cached for up to the 60s TTL. + const cache = await redis.get(cacheKeys.pageStatus(slug)); if (cache) { setMetric(c, "OpenStatus-Cache", "HIT"); @@ -87,7 +90,7 @@ status.get("/:slug", async (c) => { }); const status = tracker.currentStatus; - await redis.set(slug, status, { ex: 60 }); // 1m cache + await redis.set(cacheKeys.pageStatus(slug), status, { ex: 60 }); // 1m cache return c.json({ status }); } catch (e) { diff --git a/apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts b/apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts index 856b43e6..a455cf0e 100644 --- a/apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts +++ b/apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts @@ -848,6 +848,21 @@ describe("NotificationService.SendTestNotification", () => { expect(data.message).toContain("not supported"); }); + test("rejects a plan-gated provider on the free plan before sending", async () => { + const res = await connectRequest( + "SendTestNotification", + { + provider: "NOTIFICATION_PROVIDER_PAGERDUTY", + data: { pagerduty: { integrationKey: "free-plan-key" } }, + }, + { "x-openstatus-key": String(OTHER_WORKSPACE_ID) }, + ); + + expect(res.status).toBe(429); + const data = await res.json(); + expect(data.message).toContain("pagerduty"); + }); + test("returns error for unsupported SMS provider", async () => { const res = await connectRequest( "SendTestNotification", diff --git a/apps/server/src/routes/rpc/handlers/notification/index.ts b/apps/server/src/routes/rpc/handlers/notification/index.ts index 72c37365..2a41ac36 100644 --- a/apps/server/src/routes/rpc/handlers/notification/index.ts +++ b/apps/server/src/routes/rpc/handlers/notification/index.ts @@ -1,7 +1,8 @@ import type { ServiceImpl } from "@connectrpc/connect"; import type { NotificationService } from "@openstatus/proto/notification/v1"; -import { ForbiddenError } from "@openstatus/services"; +import { ForbiddenError, requireScope } from "@openstatus/services"; import { + assertProviderAllowed, createNotification, deleteNotification, getNotification, @@ -209,13 +210,17 @@ export const notificationServiceImpl: ServiceImpl = } }, - async sendTestNotification(req, _ctx) { + async sendTestNotification(req, ctx) { // Wrapped in `toConnectError` for symmetry with the CRUD handlers // above — any `ServiceError` / `ZodError` thrown from within // `test-providers.ts` (or a future helper it grows) gets mapped // to the right gRPC status instead of falling through to the // interceptor's generic catch. try { + // Sends from platform credentials to a caller-chosen destination. + const sCtx = toServiceCtx(getRpcContext(ctx)); + requireScope(sCtx, "write"); + assertProviderAllowed(sCtx.workspace, protoProviderToDb(req.provider)); return await sendTestNotification(req.provider, req.data); } catch (err) { toConnectError(err); diff --git a/apps/server/src/routes/rpc/handlers/notification/test-providers.ts b/apps/server/src/routes/rpc/handlers/notification/test-providers.ts index 98be8246..ec6e1314 100644 --- a/apps/server/src/routes/rpc/handlers/notification/test-providers.ts +++ b/apps/server/src/routes/rpc/handlers/notification/test-providers.ts @@ -71,7 +71,11 @@ export async function sendTestNotification( "Expected grafana_oncall data for Grafana OnCall provider", ); } - await sendGrafanaTest({ webhookUrl: data.data.value.webhookUrl }); + if ( + !(await sendGrafanaTest({ webhookUrl: data.data.value.webhookUrl })) + ) { + throw testNotificationFailedError("Failed to send test"); + } return { success: true }; } @@ -111,11 +115,12 @@ export async function sendTestNotification( "Expected ntfy data for Ntfy provider", ); } - await sendNtfyTest({ + const sent = await sendNtfyTest({ topic: data.data.value.topic, serverUrl: data.data.value.serverUrl || undefined, token: data.data.value.token, }); + if (!sent) throw testNotificationFailedError("Failed to send test"); return { success: true }; } diff --git a/apps/server/src/routes/rpc/handlers/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts index e589d23d..56711f97 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -1302,6 +1302,8 @@ export const statusPageServiceImpl: ServiceImpl = { email: req.email, pageId: pageData.id, }, + // API-key caller, page already resolved inside its own workspace. + visitor: null, }); const row = await db diff --git a/apps/server/src/routes/v1/monitors/summary/get.ts b/apps/server/src/routes/v1/monitors/summary/get.ts index 22320e12..f32eead5 100644 --- a/apps/server/src/routes/v1/monitors/summary/get.ts +++ b/apps/server/src/routes/v1/monitors/summary/get.ts @@ -2,6 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; +import { cacheKeys } from "@/libs/cache-keys"; import { redis, tb } from "@/libs/clients"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; @@ -57,7 +58,9 @@ export function registerGetMonitorSummary(api: typeof monitorsApi) { }); } - const cache = await redis.get(`${id}-daily-stats`); + const cache = await redis.get( + cacheKeys.monitorDailyStats(id), + ); if (cache) { // c.get("event").cache_hit = true; @@ -70,7 +73,7 @@ export function registerGetMonitorSummary(api: typeof monitorsApi) { ? await tb.legacy_httpStatus45d({ monitorId: id }) : await tb.legacy_tcpStatus45d({ monitorId: id }); - await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); + await redis.set(cacheKeys.monitorDailyStats(id), res.data, { ex: 600 }); return c.json({ data: res.data }, 200); }); diff --git a/apps/status-page/AGENTS.md b/apps/status-page/AGENTS.md index e072a5b8..75e975d1 100644 --- a/apps/status-page/AGENTS.md +++ b/apps/status-page/AGENTS.md @@ -12,11 +12,13 @@ applies. The existing public representations to mirror are `apps/status-page/src/app/api/markdown/[[...path]]` and `apps/status-page/src/app/api/status/[[...path]]`. -**Known open leak:** the public tRPC endpoint at `/api/trpc/lambda` serves -gated page content. `guardTRPCSource` only filters on a spoofable -`x-trpc-source` header and is explicitly not a security boundary. Do not treat -it as one, and do not widen the surface until the procedures themselves check -the gate. +The gate decision lives in `packages/services/src/page-access`, so every +transport shares it. The `statusPage` tRPC procedures enforce it themselves: +`get`/`getLight` return chrome only to a denied caller — login, layout and OG +still render from them — and the detail procedures throw. A new procedure that +returns page content must call `assertPageAccess` from +`packages/api/src/lib/page-access.ts`. `guardTRPCSource` filters on a spoofable +header and is not a security boundary. Never log or report tRPC `input` — it carries page passwords and subscriber tokens. `sentryLoggerLink` attaches the operation `path` only. diff --git a/apps/status-page/package.json b/apps/status-page/package.json index 572df41e..fff752e1 100644 --- a/apps/status-page/package.json +++ b/apps/status-page/package.json @@ -37,6 +37,7 @@ "@openstatus/icons": "workspace:*", "@openstatus/locales": "workspace:*", "@openstatus/react": "workspace:*", + "@openstatus/services": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts index 4208f533..8010bbb8 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts @@ -44,7 +44,10 @@ export async function GET( } const page = await queryClient.fetchQuery( - trpc.statusPage.get.queryOptions({ slug: domain }), + trpc.statusPage.get.queryOptions({ + slug: domain, + pw: new URL(_request.url).searchParams.get("pw"), + }), ); if (!page) return notFound(); diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts index 600d02f7..0584a253 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts @@ -40,7 +40,10 @@ export async function GET( } const page = await queryClient.fetchQuery( - trpc.statusPage.get.queryOptions({ slug: domain }), + trpc.statusPage.get.queryOptions({ + slug: domain, + pw: new URL(_request.url).searchParams.get("pw"), + }), ); if (!page) return notFound(); diff --git a/apps/status-page/src/app/api/markdown/[[...path]]/route.ts b/apps/status-page/src/app/api/markdown/[[...path]]/route.ts index 5f8868af..2c1c3e8f 100644 --- a/apps/status-page/src/app/api/markdown/[[...path]]/route.ts +++ b/apps/status-page/src/app/api/markdown/[[...path]]/route.ts @@ -1,3 +1,4 @@ +import { resolveClientIp } from "@openstatus/services/page-access"; import { cookies, headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; @@ -12,7 +13,6 @@ import { parseMarkdownPath, } from "../../../../content/markdown"; import { getBaseUrl } from "../../../../lib/base-url"; -import { resolveClientIp } from "../../../../lib/http/client-ip"; import { resolveMarkdownResponse } from "../../../../lib/http/markdown-response"; import { type GatePage, resolveGate } from "../../../../lib/proxy/resolve-gate"; import { getQueryClient, trpc } from "../../../../lib/trpc/server"; @@ -65,6 +65,7 @@ export async function GET( const source = request.headers.get("x-md-source"); const queryClient = getQueryClient(); const url = new URL(request.url); + const pw = url.searchParams.get("pw"); const cookieStore = await cookies(); const headerStore = await headers(); const clientIp = resolveClientIp(headerStore); @@ -88,7 +89,7 @@ export async function GET( case "monitors": case "events": { const page = await queryClient.fetchQuery( - trpc.statusPage.get.queryOptions({ slug }), + trpc.statusPage.get.queryOptions({ slug, pw }), ); if (!page) return textResponse("Not Found", 404); const denied = await denyResponse(page); @@ -127,6 +128,7 @@ export async function GET( (await queryClient.fetchQuery( trpc.statusPage.getUptime.queryOptions({ slug, + pw, pageComponentIds: page.pageComponents.map((c) => c.id.toString()), cardType, barType, @@ -160,7 +162,11 @@ export async function GET( if (target.kind === "monitor") { const monitor = await queryClient.fetchQuery( - trpc.statusPage.getMonitor.queryOptions({ slug, id: target.id }), + trpc.statusPage.getMonitor.queryOptions({ + slug, + id: target.id, + pw, + }), ); if (!monitor) return textResponse("Not Found", 404); return markdownResponse( @@ -176,7 +182,7 @@ export async function GET( } if (target.kind === "report") { const report = await queryClient.fetchQuery( - trpc.statusPage.getReport.queryOptions({ slug, id: target.id }), + trpc.statusPage.getReport.queryOptions({ slug, id: target.id, pw }), ); if (!report) return textResponse("Not Found", 404); return markdownResponse( @@ -191,7 +197,11 @@ export async function GET( ); } const maintenance = await queryClient.fetchQuery( - trpc.statusPage.getMaintenance.queryOptions({ slug, id: target.id }), + trpc.statusPage.getMaintenance.queryOptions({ + slug, + id: target.id, + pw, + }), ); if (!maintenance) return textResponse("Not Found", 404); return markdownResponse( diff --git a/apps/status-page/src/app/api/status/[[...path]]/route.ts b/apps/status-page/src/app/api/status/[[...path]]/route.ts index 6ee8bb27..2b198e16 100644 --- a/apps/status-page/src/app/api/status/[[...path]]/route.ts +++ b/apps/status-page/src/app/api/status/[[...path]]/route.ts @@ -1,5 +1,6 @@ import { db, sql } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; +import { resolveClientIp } from "@openstatus/services/page-access"; import { cookies, headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; @@ -11,7 +12,6 @@ import { } from "../../../../content/status-json"; import { getBaseUrl } from "../../../../lib/base-url"; import { stripHostPort } from "../../../../lib/domain"; -import { resolveClientIp } from "../../../../lib/http/client-ip"; import { computeETag, isNotModified } from "../../../../lib/http/etag"; import { resolveGate } from "../../../../lib/proxy/resolve-gate"; import { resolveRoute } from "../../../../lib/resolve-route"; @@ -66,7 +66,10 @@ export async function GET( const queryClient = getQueryClient(); const data = await queryClient.fetchQuery( - trpc.statusPage.get.queryOptions({ slug: row.slug }), + trpc.statusPage.get.queryOptions({ + slug: row.slug, + pw: url.searchParams.get("pw"), + }), ); if (!data) return json({ error: "Not Found" }, 404); diff --git a/apps/status-page/src/lib/protected.ts b/apps/status-page/src/lib/protected.ts index 9f7750c6..3b755d1f 100644 --- a/apps/status-page/src/lib/protected.ts +++ b/apps/status-page/src/lib/protected.ts @@ -1,3 +1 @@ -export function createProtectedCookieKey(value: string) { - return `secured-${value}`; -} +export { pageAccessCookieKey as createProtectedCookieKey } from "@openstatus/services/page-access/cookie-key"; diff --git a/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts b/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts index 99c8b60b..2c9d880d 100644 --- a/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-email-domain-action.ts @@ -1,6 +1,6 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isEmailDomainAuthorized } from "@openstatus/services/page-access"; -import { isEmailDomainAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; import type { Action, ComposeInput } from "./types"; diff --git a/apps/status-page/src/lib/proxy/resolve-gate.ts b/apps/status-page/src/lib/proxy/resolve-gate.ts index fb6ca343..39470900 100644 --- a/apps/status-page/src/lib/proxy/resolve-gate.ts +++ b/apps/status-page/src/lib/proxy/resolve-gate.ts @@ -1,13 +1,13 @@ import type { Page } from "@openstatus/db/src/schema"; +import { + evaluateMarkdownGate, + type MarkdownGateResult, +} from "@openstatus/services/page-access"; import { auth } from "../auth"; import { createProtectedCookieKey } from "../protected"; import type { getQueryClient } from "../trpc/server"; import { trpc } from "../trpc/server"; -import { - evaluateMarkdownGate, - type MarkdownGateResult, -} from "./evaluate-markdown-gate"; export type GatePage = { accessType: Page["accessType"]; diff --git a/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts b/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts index 494e27b1..a983685a 100644 --- a/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-ip-restriction-action.ts @@ -1,6 +1,6 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isIpAuthorized } from "@openstatus/services/page-access"; -import { isIpAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; import type { Action, ComposeInput } from "./types"; diff --git a/apps/status-page/src/lib/proxy/resolve-password-action.ts b/apps/status-page/src/lib/proxy/resolve-password-action.ts index 07f46ce8..9a3a19b9 100644 --- a/apps/status-page/src/lib/proxy/resolve-password-action.ts +++ b/apps/status-page/src/lib/proxy/resolve-password-action.ts @@ -1,6 +1,6 @@ import type { Page } from "@openstatus/db/src/schema"; +import { isPasswordAuthorized } from "@openstatus/services/page-access"; -import { isPasswordAuthorized } from "./access-predicates"; import { buildExternalPath } from "./build-external-path"; import type { Action, ComposeInput } from "./types"; diff --git a/apps/status-page/src/lib/trpc/shared.ts b/apps/status-page/src/lib/trpc/shared.ts index 3519e0b4..f08df1bc 100644 --- a/apps/status-page/src/lib/trpc/shared.ts +++ b/apps/status-page/src/lib/trpc/shared.ts @@ -37,7 +37,8 @@ export const sentryLoggerLink = (): TRPCLink => * 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. + * the header is trivially spoofable. Auth is enforced by protectedProcedure, + * and page gating by the statusPage procedures themselves. */ export function guardTRPCSource(req: Request): Response | null { const source = req.headers.get("x-trpc-source"); diff --git a/apps/status-page/src/proxy.ts b/apps/status-page/src/proxy.ts index c061682b..4a13947e 100644 --- a/apps/status-page/src/proxy.ts +++ b/apps/status-page/src/proxy.ts @@ -1,9 +1,9 @@ import { db, sql } from "@openstatus/db"; import { page, selectPageSchema } from "@openstatus/db/src/schema"; +import { resolveClientIp } from "@openstatus/services/page-access"; import { NextResponse } from "next/server"; import { auth } from "./lib/auth"; -import { resolveClientIp } from "./lib/http/client-ip"; import { createProtectedCookieKey } from "./lib/protected"; import { applyPageLocaleOverride } from "./lib/proxy/apply-page-locale-override"; import { applyPageSlugPrefix } from "./lib/proxy/apply-page-slug-prefix"; @@ -26,8 +26,11 @@ export default auth(async (req) => { // HTML served via internal rewrite shares its URL with the markdown variant — // carry the same Vary as the passthrough so caches don't cross them. - const rewriteWithVary = (target: URL) => { - const response = NextResponse.rewrite(target); + const rewriteWithVary = ( + target: URL, + init?: Parameters[1], + ) => { + const response = NextResponse.rewrite(target, init); response.headers.set("Vary", "Accept"); return response; }; @@ -97,6 +100,7 @@ export default auth(async (req) => { ); const clientIp = resolveClientIp(req.headers); + const queryPassword = url.searchParams.get("pw"); console.log("[proxy] request", { host, @@ -122,7 +126,7 @@ export default auth(async (req) => { origin: req.nextUrl.origin, cookiePassword: req.cookies.get(createProtectedCookieKey(_page.slug)) ?.value, - queryPassword: url.searchParams.get("pw"), + queryPassword, redirectParam: sanitizeRedirectParam(url.searchParams.get("redirect")), authEmail: req.auth?.user?.email, clientIp, @@ -134,16 +138,35 @@ export default auth(async (req) => { url: action.url?.toString() ?? null, }); + // A `?pw=` link carries no cookie yet, and the tRPC gate downstream only + // sees cookies — forward the password as one on the internal request. + const request = + _page.accessType === "password" && queryPassword + ? { headers: withPasswordCookie(req.headers, _page.slug, queryPassword) } + : undefined; + switch (action.type) { case "redirect": return NextResponse.redirect(action.url); case "rewrite": - return rewriteWithVary(action.url); - case "passthrough": - return passthroughResponse; + return rewriteWithVary(action.url, { request }); + case "passthrough": { + if (!request) return passthroughResponse; + const response = NextResponse.next({ request }); + response.headers.set("Vary", "Accept"); + return response; + } } }); +function withPasswordCookie(headers: Headers, slug: string, password: string) { + const next = new Headers(headers); + const cookie = `${createProtectedCookieKey(slug)}=${encodeURIComponent(password)}`; + const existing = headers.get("cookie"); + next.set("cookie", existing ? `${existing}; ${cookie}` : cookie); + return next; +} + export const config = { matcher: [ "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", diff --git a/apps/web/src/app/api/webhook/stripe/route.ts b/apps/web/src/app/api/webhook/stripe/route.ts index 291d699b..df14816c 100644 --- a/apps/web/src/app/api/webhook/stripe/route.ts +++ b/apps/web/src/app/api/webhook/stripe/route.ts @@ -1,5 +1,5 @@ import { createTRPCContext } from "@openstatus/api"; -import { lambdaRouter, stripe } from "@openstatus/api/src/lambda"; +import { stripe, webhookRouter } from "@openstatus/api/src/lambda"; import { TRPCError } from "@trpc/server"; import { getHTTPStatusCodeFromError } from "@trpc/server/http"; import type { NextRequest } from "next/server"; @@ -22,24 +22,24 @@ export async function POST(req: NextRequest) { * Forward to tRPC API to handle the webhook event */ const ctx = await createTRPCContext({ req }); - const caller = lambdaRouter.createCaller(ctx); + const caller = webhookRouter.createCaller(ctx); switch (event.type) { case "checkout.session.completed": - await caller.stripeRouter.webhooks.sessionCompleted({ event }); + await caller.sessionCompleted({ event }); break; case "customer.subscription.updated": - await caller.stripeRouter.webhooks.customerSubscriptionUpdated({ + await caller.customerSubscriptionUpdated({ event, }); break; case "customer.subscription.trial_will_end": - await caller.stripeRouter.webhooks.customerSubscriptionTrialWillEnd({ + await caller.customerSubscriptionTrialWillEnd({ event, }); break; case "customer.subscription.deleted": - await caller.stripeRouter.webhooks.customerSubscriptionDeleted({ + await caller.customerSubscriptionDeleted({ event, }); break; diff --git a/packages/api/src/lambda.ts b/packages/api/src/lambda.ts index a74efbfe..4a5cc87f 100644 --- a/packages/api/src/lambda.ts +++ b/packages/api/src/lambda.ts @@ -18,3 +18,4 @@ export const lambdaRouter = createTRPCRouter({ }); export { stripe } from "./router/stripe/shared"; +export { webhookRouter } from "./router/stripe/webhook"; diff --git a/packages/api/src/lib/page-access.ts b/packages/api/src/lib/page-access.ts new file mode 100644 index 00000000..622b23f2 --- /dev/null +++ b/packages/api/src/lib/page-access.ts @@ -0,0 +1,45 @@ +import { + assertPageAccess as assertAccess, + type PageVisitor, + resolveClientIp, + resolvePageAccess as resolveAccess, +} from "@openstatus/services/page-access"; + +import { toTRPCError } from "../service-adapter"; +import type { Context } from "../trpc"; + +type Ctx = Pick; +type AccessRow = Parameters[0]; + +/** `queryPassword` is for cookie-less server callers (feeds, `?pw=` links). */ +export function visitorFromCtx( + ctx: Ctx, + queryPassword?: string | null, +): PageVisitor { + return { + getCookie: (name) => ctx.req?.cookies.get(name)?.value, + queryPassword, + email: ctx.session?.user?.email, + clientIp: ctx.req ? resolveClientIp(ctx.req.headers) : null, + }; +} + +export function resolvePageAccess( + ctx: Ctx, + row: AccessRow, + queryPassword?: string | null, +) { + return resolveAccess(row, visitorFromCtx(ctx, queryPassword)); +} + +export function assertPageAccess( + ctx: Ctx, + row: AccessRow, + queryPassword?: string | null, +) { + try { + assertAccess(row, visitorFromCtx(ctx, queryPassword)); + } catch (err) { + toTRPCError(err); + } +} diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts index a380f38c..30a37385 100644 --- a/packages/api/src/lib/vercel.ts +++ b/packages/api/src/lib/vercel.ts @@ -6,8 +6,21 @@ import { env } from "../env"; // Vercel domain helpers — transport-layer external integrations that // don't belong in the service layer. +const VERCEL_API_ORIGIN = "https://api.vercel.com"; + export async function vercelFetch(path: string, init?: RequestInit) { - return fetch(`https://api.vercel.com${path}`, { + // URL parsing resolves `..` segments and `#` cuts the query — refuse any + // path that doesn't survive normalization unchanged. + const url = new URL(path, VERCEL_API_ORIGIN); + if ( + url.origin !== VERCEL_API_ORIGIN || + url.hash || + `${url.pathname}${url.search}` !== path + ) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid path." }); + } + + return fetch(url, { ...init, headers: { Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, diff --git a/packages/api/src/router/domain.test.ts b/packages/api/src/router/domain.test.ts new file mode 100644 index 00000000..12f56634 --- /dev/null +++ b/packages/api/src/router/domain.test.ts @@ -0,0 +1,139 @@ +import { db, eq } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; +import { + createPage, + createTestWorkspace, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, test } from "@std/testing/bdd"; +import { TRPCError } from "@trpc/server"; + +import { edgeRouter } from "../edge"; +import { vercelFetch } from "../lib/vercel"; +import { createInnerTRPCContext } from "../trpc"; + +const otherDomain = "domain-idor-test.openstatus.dev"; +let otherPageId: number; +let ownWorkspaceId: number; +let ownUserId: number; + +function getCaller() { + const ctx = createInnerTRPCContext({ + req: undefined, + session: { user: { id: String(ownUserId) } }, + // @ts-expect-error - minimal user for test + user: { id: ownUserId }, + // @ts-expect-error - minimal workspace for test + workspace: { id: ownWorkspaceId }, + }); + return edgeRouter.createCaller(ctx); +} + +beforeAll(async () => { + const own = await createTestWorkspace(); + ownWorkspaceId = own.workspace.id; + ownUserId = own.user.id; + const other = await createTestWorkspace(); + + const row = await createPage(other.workspace.id, { + customDomain: otherDomain, + }); + otherPageId = row.id; +}); + +afterAll(async () => { + await db.delete(page).where(eq(page.id, otherPageId)); +}); + +for (const procedure of [ + "getDomainResponse", + "getConfigResponse", + "verifyDomain", +] as const) { + test(`domain.${procedure} rejects another workspace's domain`, async () => { + const error = await getCaller() + .domain[procedure]({ domain: otherDomain }) + .catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("NOT_FOUND"); + }); + + test(`domain.${procedure} input schema rejects a leading-dot payload`, async () => { + const error = await getCaller() + .domain[procedure]({ domain: "../../../../v2/user#" }) + .catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("BAD_REQUEST"); + }); +} + +// `customDomainSchema` ends in `.*`, so this passes input validation; owning it +// passes the ownership check. Only the path encoding keeps it inside /domains/. +for (const procedure of [ + "getDomainResponse", + "getConfigResponse", + "verifyDomain", +] as const) { + test(`domain.${procedure} cannot escape the domains path with an owned traversal domain`, async () => { + const traversal = `evil-${ownWorkspaceId}.example/../../../../v2/user#`; + const own = await createPage(ownWorkspaceId, { customDomain: traversal }); + + const original = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (input) => { + requested.push(String(input)); + return Promise.resolve(Response.json({})); + }; + try { + await getCaller().domain[procedure]({ domain: traversal }); + expect(requested.length).toBe(1); + const url = new URL(requested[0]); + expect(url.pathname).toContain("/domains/"); + expect(url.pathname).toContain(encodeURIComponent(traversal)); + expect(url.pathname).not.toContain("/v2/user"); + expect(url.searchParams.has("teamId")).toBe(true); + } finally { + globalThis.fetch = original; + await db.delete(page).where(eq(page.id, own.id)); + } + }); +} + +test("domain.getDomainResponse reaches Vercel for the workspace's own domain", async () => { + const ownDomain = `own-${ownWorkspaceId}.openstatus.dev`; + const own = await createPage(ownWorkspaceId, { customDomain: ownDomain }); + + const original = globalThis.fetch; + let requested = ""; + globalThis.fetch = (input) => { + requested = String(input); + return Promise.resolve(Response.json({ name: ownDomain, verified: true })); + }; + try { + const result = await getCaller().domain.getDomainResponse({ + domain: ownDomain.toUpperCase(), + }); + expect(result?.verified).toBe(true); + expect( + new URL(requested).pathname.endsWith( + `/domains/${ownDomain.toUpperCase()}`, + ), + ).toBe(true); + } finally { + globalThis.fetch = original; + await db.delete(page).where(eq(page.id, own.id)); + } +}); + +for (const path of [ + "/v9/projects/p/domains/../../../../v2/user", + "/v9/projects/p/domains/%2e%2e/%2e%2e/x", + "/v9/projects/p/domains/a.com#?teamId=t", + "//evil.example/v2/user", +]) { + test(`vercelFetch refuses ${path}`, async () => { + const error = await vercelFetch(path).catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("BAD_REQUEST"); + }); +} diff --git a/packages/api/src/router/domain.ts b/packages/api/src/router/domain.ts index 6b8d2773..a127d7c7 100644 --- a/packages/api/src/router/domain.ts +++ b/packages/api/src/router/domain.ts @@ -1,7 +1,10 @@ +import { customDomainSchema } from "@openstatus/db/src/schema/pages/validation"; +import { assertCustomDomainInWorkspace } from "@openstatus/services/page"; import { z } from "zod"; import { env } from "../env"; import { vercelFetch } from "../lib/vercel"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const domainConfigResponseSchema = z.object({ @@ -51,15 +54,32 @@ export type DomainVerificationStatusProps = | "Domain Not Found" | "Unknown Error"; +async function assertOwned( + ctx: Parameters[0], + domain: string, +) { + try { + await assertCustomDomainInWorkspace({ + ctx: toServiceCtx(ctx), + input: { domain }, + }); + } catch (err) { + toTRPCError(err); + } +} + +const domainInput = z.object({ domain: customDomainSchema.optional() }); + export const domainRouter = createTRPCRouter({ getDomainResponse: protectedProcedure - .input(z.object({ domain: z.string().optional() })) + .input(domainInput) .query(async (opts) => { if (!opts.input.domain) { return null; } + await assertOwned(opts.ctx, opts.input.domain); const data = await vercelFetch( - `/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${opts.input.domain}?teamId=${env.TEAM_ID_VERCEL}`, + `/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${encodeURIComponent(opts.input.domain)}?teamId=${env.TEAM_ID_VERCEL}`, ); const json = await data.json(); const result = domainResponseSchema @@ -72,34 +92,33 @@ export const domainRouter = createTRPCRouter({ .optional(), }) .parse(json); - console.log({ result }); return result; }), getConfigResponse: protectedProcedure - .input(z.object({ domain: z.string().optional() })) + .input(domainInput) .query(async (opts) => { if (!opts.input.domain) { return null; } + await assertOwned(opts.ctx, opts.input.domain); const data = await vercelFetch( - `/v6/domains/${opts.input.domain}/config?teamId=${env.TEAM_ID_VERCEL}`, + `/v6/domains/${encodeURIComponent(opts.input.domain)}/config?teamId=${env.TEAM_ID_VERCEL}`, ); const json = await data.json(); const result = domainConfigResponseSchema.parse(json); return result; }), - verifyDomain: protectedProcedure - .input(z.object({ domain: z.string().optional() })) - .query(async (opts) => { - if (!opts.input.domain) { - return null; - } - const data = await vercelFetch( - `/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${opts.input.domain}/verify?teamId=${env.TEAM_ID_VERCEL}`, - { method: "POST" }, - ); - const json = await data.json(); - const result = domainResponseSchema.parse(json); - return result; - }), + verifyDomain: protectedProcedure.input(domainInput).query(async (opts) => { + if (!opts.input.domain) { + return null; + } + await assertOwned(opts.ctx, opts.input.domain); + const data = await vercelFetch( + `/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${encodeURIComponent(opts.input.domain)}/verify?teamId=${env.TEAM_ID_VERCEL}`, + { method: "POST" }, + ); + const json = await data.json(); + const result = domainResponseSchema.parse(json); + return result; + }), }); diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts index f68ef667..c25b1e3e 100644 --- a/packages/api/src/router/email/index.ts +++ b/packages/api/src/router/email/index.ts @@ -120,7 +120,7 @@ export const emailRouter = createTRPCRouter({ }), sendTeamInvitation: protectedProcedure - .input(z.object({ id: z.number(), baseUrl: z.string().optional() })) + .input(z.object({ id: z.number() })) .mutation(async (opts) => { const limits = opts.ctx.workspace.limits; @@ -139,7 +139,6 @@ export const emailRouter = createTRPCRouter({ token: _invitation.token, invitedBy: `${opts.ctx.user.email}`, workspaceName: opts.ctx.workspace.name || "openstatus", - baseUrl: opts.input.baseUrl, }); } }), diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index e40c63a4..bc2419a3 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -192,7 +192,12 @@ export const notificationRouter = createTRPCRouter({ }); } - await sendGrafanaTest(_data.data["grafana-oncall"]); + if (!(await sendGrafanaTest(_data.data["grafana-oncall"]))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to send test", + }); + } return; } if (opts.input.provider === "ms-teams") { @@ -269,7 +274,12 @@ export const notificationRouter = createTRPCRouter({ }); } - await sendNtfyTest(_data.data.ntfy); + if (!(await sendNtfyTest(_data.data.ntfy))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to send test", + }); + } return; } if (opts.input.provider === "pagerduty") { diff --git a/packages/api/src/router/pageSubscriber.ts b/packages/api/src/router/pageSubscriber.ts index 1e8c4750..9460889c 100644 --- a/packages/api/src/router/pageSubscriber.ts +++ b/packages/api/src/router/pageSubscriber.ts @@ -1,4 +1,5 @@ import { Events } from "@openstatus/analytics"; +import { ForbiddenError, UnauthorizedError } from "@openstatus/services"; import { SAFE_SUBSCRIPTION_MESSAGES, createPageSubscriber, @@ -16,6 +17,7 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { visitorFromCtx } from "../lib/page-access"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; @@ -37,6 +39,9 @@ const supportedWebhookUrlSchema = z.url(); // subscriptions error message only needs adding in one place. function throwFromException(error: unknown, fallback: string): never { if (error instanceof TRPCError) throw error; + if (error instanceof UnauthorizedError || error instanceof ForbiddenError) { + toTRPCError(error); + } console.error("pageSubscriber router error:", error); if (error instanceof Error && SAFE_SUBSCRIPTION_MESSAGES.has(error.message)) { throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); @@ -61,7 +66,8 @@ export const pageSubscriberRouter = createTRPCRouter({ .mutation(async (opts) => { const isPending = await hasPendingSubscriber({ input: { email: opts.input.email, pageId: opts.input.pageId }, - }); + visitor: visitorFromCtx(opts.ctx), + }).catch((error) => throwFromException(error, "Failed to subscribe")); if (isPending) { throw new TRPCError({ code: "BAD_REQUEST", @@ -77,6 +83,7 @@ export const pageSubscriberRouter = createTRPCRouter({ pageId: opts.input.pageId, componentIds: opts.input.componentIds, }, + visitor: visitorFromCtx(opts.ctx), }); return { diff --git a/packages/api/src/router/statusPage.access.test.ts b/packages/api/src/router/statusPage.access.test.ts new file mode 100644 index 00000000..4376987f --- /dev/null +++ b/packages/api/src/router/statusPage.access.test.ts @@ -0,0 +1,250 @@ +import { db, eq } from "@openstatus/db"; +import { page, pageComponent, statusReport } from "@openstatus/db/src/schema"; +import { + createPage, + createPageComponent, + createTestWorkspace, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { TRPCError } from "@trpc/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { NextRequest } from "next/server.js"; + +import { appRouter } from "../root"; +import { createInnerTRPCContext, createTRPCContext } from "../trpc"; + +const PASSWORD = "s3cret-pw"; +let workspaceId: number; +const slugs = {} as Record< + "public" | "password" | "email-domain" | "ip-restriction", + string +>; +const reportIds = {} as Record; +const pageIds = {} as Record; + +function caller(opts?: { headers?: Record; email?: string }) { + return appRouter.createCaller( + createInnerTRPCContext({ + req: new NextRequest("http://rsc.internal", { headers: opts?.headers }), + session: opts?.email ? { user: { email: opts.email } } : null, + }), + ); +} + +beforeAll(async () => { + const fixture = await createTestWorkspace(); + workspaceId = fixture.workspace.id; + + const variants = { + public: {}, + password: { password: PASSWORD }, + "email-domain": { authEmailDomains: "acme.com" }, + "ip-restriction": { allowedIpRanges: "10.0.0.0/8" }, + } as const; + + for (const accessType of Object.keys(variants) as (keyof typeof slugs)[]) { + const _page = await createPage(workspaceId, { + accessType, + ...variants[accessType], + }); + slugs[accessType] = _page.slug; + pageIds[accessType] = _page.id; + await createPageComponent(workspaceId, _page.id); + const report = await db + .insert(statusReport) + .values({ + workspaceId, + pageId: _page.id, + title: "confidential incident", + status: "investigating", + }) + .returning() + .get(); + reportIds[accessType] = report.id; + } +}); + +afterAll(async () => { + await db + .delete(statusReport) + .where(eq(statusReport.workspaceId, workspaceId)); + await db + .delete(pageComponent) + .where(eq(pageComponent.workspaceId, workspaceId)); + await db.delete(page).where(eq(page.workspaceId, workspaceId)); +}); + +const authorized = { + public: () => caller(), + password: () => + caller({ headers: { cookie: `secured-${slugs.password}=${PASSWORD}` } }), + "email-domain": () => caller({ email: "jane@ACME.com" }), + "ip-restriction": () => caller({ headers: { "x-real-ip": "10.1.2.3" } }), +}; + +const denied = { + password: () => + caller({ headers: { cookie: `secured-${slugs.password}=wrong` } }), + "email-domain": () => caller({ email: "jane@evil.com" }), + "ip-restriction": () => caller({ headers: { "x-real-ip": "8.8.8.8" } }), +}; + +// The reported exploit: anonymous HTTP call with a spoofed `x-trpc-source`. +function httpCall(path: string, input: unknown, headers?: HeadersInit) { + const url = new URL(`http://status.test/api/trpc/lambda/${path}`); + url.searchParams.set("input", JSON.stringify({ json: input })); + const req = new NextRequest(url, { + headers: { "x-trpc-source": "client", ...headers }, + }); + return fetchRequestHandler({ + endpoint: "/api/trpc/lambda", + router: appRouter, + req, + createContext: () => createTRPCContext({ req }), + }); +} + +describe("statusPage over HTTP", () => { + test("anonymous callers get no protected content by slug", async () => { + const slug = slugs.password; + // chrome-only procedures answer 200 with a redacted page, the rest deny + const calls: [string, unknown, number][] = [ + ["statusPage.get", { slug }, 200], + ["statusPage.get", { slug: slug.toUpperCase() }, 200], + ["statusPage.get", { slug, pw: "guess" }, 200], + ["statusPage.get", { slug, pw: "" }, 200], + ["statusPage.getLight", { slug }, 200], + ["statusPage.getGate", { slug }, 200], + ["statusPage.getReport", { slug, id: reportIds.password }, 401], + ["statusPage.getMonitors", { slug }, 401], + ["statusPage.getMonitors", { slug, pw: "guess" }, 401], + ["statusPage.getUptime", { slug, pageComponentIds: [] }, 401], + ]; + for (const [path, input, status] of calls) { + const res = await httpCall(path, input); + expect(res.status).toBe(status); + const body = await res.text(); + expect(body).not.toContain("confidential"); + expect(body).not.toContain(PASSWORD); + expect(body).not.toContain("test-component-"); + } + }); + + test("the right cookie or `pw` still gets the content", async () => { + const slug = slugs.password; + const viaCookie = await httpCall( + "statusPage.getReport", + { slug, id: reportIds.password }, + { cookie: `secured-${slug}=${PASSWORD}` }, + ); + expect(viaCookie.status).toBe(200); + expect(await viaCookie.text()).toContain("confidential"); + const viaPw = await httpCall("statusPage.get", { slug, pw: PASSWORD }); + expect(viaPw.status).toBe(200); + expect(await viaPw.text()).toContain("confidential"); + }); +}); + +describe("statusPage access gate", () => { + for (const accessType of Object.keys(authorized) as (keyof typeof slugs)[]) { + test(`${accessType}: authorized caller gets the full page`, async () => { + const c = authorized[accessType](); + const slug = slugs[accessType]; + const data = await c.statusPage.get({ slug }); + expect(data?.statusReports.length).toBe(1); + expect(data?.pageComponents.length).toBe(1); + const report = await c.statusPage.getReport({ + slug, + id: reportIds[accessType], + }); + expect(report?.title).toBe("confidential incident"); + }); + } + + for (const accessType of Object.keys(denied) as (keyof typeof denied)[]) { + for (const [label, make] of [ + ["anonymous", () => caller()], + ["wrong credentials", denied[accessType]], + ] as const) { + test(`${accessType}: ${label} caller gets chrome only`, async () => { + const c = make(); + const slug = slugs[accessType]; + + for (const data of [ + await c.statusPage.get({ slug }), + await c.statusPage.getLight({ slug }), + ]) { + expect(data?.title).toBe("Test Page"); + expect(data?.accessType).toBe(accessType); + expect(data?.statusReports).toEqual([]); + expect(data?.maintenances).toEqual([]); + expect(data?.pageComponents).toEqual([]); + expect(data?.monitors).toEqual([]); + expect(JSON.stringify(data)).not.toContain("confidential"); + expect(JSON.stringify(data)).not.toContain(PASSWORD); + } + + const attempts = [ + () => c.statusPage.getReport({ slug, id: reportIds[accessType] }), + () => c.statusPage.getMaintenance({ slug, id: 1 }), + () => c.statusPage.getMonitors({ slug }), + () => c.statusPage.getMonitor({ slug, id: 1 }), + () => c.statusPage.getUptime({ slug, pageComponentIds: [] }), + () => + c.statusPage.subscribe({ + slug, + email: "outsider@example.com", + subscribeComponents: false, + pageComponents: [], + }), + ]; + for (const attempt of attempts) { + const error = await attempt().catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect(["UNAUTHORIZED", "FORBIDDEN"]).toContain( + (error as TRPCError).code, + ); + } + }); + } + } + + for (const accessType of Object.keys(denied) as (keyof typeof denied)[]) { + test(`${accessType}: pageSubscriber.upsert by page id is gated`, async () => { + const error = await caller() + .pageSubscriber.upsert({ + email: "outsider@example.com", + pageId: pageIds[accessType], + }) + .catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect(["UNAUTHORIZED", "FORBIDDEN"]).toContain( + (error as TRPCError).code, + ); + }); + } + + test("password: `pw` input authorizes a cookie-less caller", async () => { + const data = await caller().statusPage.get({ + slug: slugs.password, + pw: PASSWORD, + }); + expect(data?.statusReports.length).toBe(1); + }); + + test("password: a wrong `pw` does not fall through to a valid cookie", async () => { + const data = await authorized.password().statusPage.get({ + slug: slugs.password, + pw: "wrong", + }); + expect(data?.statusReports).toEqual([]); + }); + + test("ip-restriction: spoofed x-forwarded-for loses to x-real-ip", async () => { + const data = await caller({ + headers: { "x-real-ip": "8.8.8.8", "x-forwarded-for": "10.1.2.3" }, + }).statusPage.get({ slug: slugs["ip-restriction"] }); + expect(data?.statusReports).toEqual([]); + }); +}); diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 255cc6f3..19a3b02e 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -16,6 +16,7 @@ import { selectWorkspaceSchema, statusReport, } from "@openstatus/db/src/schema"; +import { constantTimeEqual } from "@openstatus/services/page-access"; import { getSubscriberByToken, hasPendingSubscriber, @@ -28,6 +29,11 @@ import { TRPCError } from "@trpc/server"; import { endOfDay, startOfDay, subDays } from "date-fns"; import { z } from "zod"; +import { + assertPageAccess, + resolvePageAccess, + visitorFromCtx, +} from "../lib/page-access"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { type StatusData, @@ -57,24 +63,6 @@ import { // NOTE: this router is used on status pages only - do not confuse with the page router which is used in the dashboard for the config -// Length-independent comparison so a wrong guess can't be timed by length or -// character. Pure JS (no node:crypto) keeps it usable from the Edge runtime. -function constantTimeEqual( - a: string | null | undefined, - b: string | null | undefined, -): boolean { - if (a == null || b == null) return false; - // constant-time: iterate over the max length and fold the length delta into - // the accumulator so we never early-return or branch on length. - const max = Math.max(a.length, b.length); - let mismatch = a.length ^ b.length; - for (let i = 0; i < max; i++) { - // out-of-range indices read as 0; mismatch already non-zero on length diff. - mismatch |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); - } - return mismatch === 0; -} - // Gate fields for getGate, reusing selectPageSchema's stringToArray transforms // so authEmailDomains / allowedIpRanges come back as arrays like getLight. const gateFieldsSchema = selectPageSchema.pick({ @@ -87,11 +75,15 @@ const gateFieldsSchema = selectPageSchema.pick({ contactUrl: true, }); +// Password for cookie-less server callers (feeds, `?pw=` links). +const queryPasswordSchema = z.string().nullish(); + export const statusPageRouter = createTRPCRouter({ get: publicProcedure .input( z.object({ slug: z.string().toLowerCase(), + pw: queryPasswordSchema, // NOTE: override the defaults we are getting from the page configuration cardType: z .enum(["requests", "duration", "dominant", "manual"]) @@ -141,6 +133,14 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + // Denied visitors still need the page chrome (login, layout, OG). + if (!resolvePageAccess(opts.ctx, _page, opts.input.pw).ok) { + _page.statusReports = []; + _page.maintenances = []; + _page.pageComponents = []; + _page.pageComponentGroups = []; + } + const ws = selectWorkspaceSchema.safeParse(_page.workspace); const pageComponents = selectPageComponentWithMonitorRelation .array() @@ -525,7 +525,12 @@ export const statusPageRouter = createTRPCRouter({ }), getLight: publicProcedure - .input(z.object({ slug: z.string().toLowerCase() })) + .input( + z.object({ + slug: z.string().toLowerCase(), + pw: queryPasswordSchema, + }), + ) .query(async (opts) => { if (!opts.input.slug) return null; @@ -562,6 +567,14 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + // Denied visitors still need the page chrome (login, layout, OG). + if (!resolvePageAccess(opts.ctx, _page, opts.input.pw).ok) { + _page.statusReports = []; + _page.maintenances = []; + _page.pageComponents = []; + _page.pageComponentGroups = []; + } + // Extract monitor components for backwards compatibility const monitorComponents = _page.pageComponents.filter( (c) => @@ -648,7 +661,13 @@ export const statusPageRouter = createTRPCRouter({ }), getMaintenance: publicProcedure - .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .input( + z.object({ + slug: z.string().toLowerCase(), + id: z.number(), + pw: queryPasswordSchema, + }), + ) .query(async (opts) => { if (!opts.input.slug) return null; @@ -662,6 +681,8 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + assertPageAccess(opts.ctx, _page, opts.input.pw); + const _maintenance = await opts.ctx.db.query.maintenance.findFirst({ where: and( eq(maintenance.id, opts.input.id), @@ -684,6 +705,7 @@ export const statusPageRouter = createTRPCRouter({ .input( z.object({ slug: z.string().toLowerCase(), + pw: queryPasswordSchema, pageComponentIds: z.string().array(), cardType: z .enum(["requests", "duration", "dominant", "manual"]) @@ -734,6 +756,8 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + assertPageAccess(opts.ctx, _page, input.pw); + const pageComponents = selectPageComponentWithMonitorRelation .array() .parse(_page.pageComponents); @@ -913,7 +937,13 @@ export const statusPageRouter = createTRPCRouter({ }), getReport: publicProcedure - .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .input( + z.object({ + slug: z.string().toLowerCase(), + id: z.number(), + pw: queryPasswordSchema, + }), + ) .query(async (opts) => { if (!opts.input.slug) return null; @@ -927,6 +957,8 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + assertPageAccess(opts.ctx, _page, opts.input.pw); + const _report = await opts.ctx.db.query.statusReport.findFirst({ where: and( eq(statusReport.id, opts.input.id), @@ -1032,7 +1064,9 @@ export const statusPageRouter = createTRPCRouter({ }), getMonitors: publicProcedure - .input(z.object({ slug: z.string().toLowerCase() })) + .input( + z.object({ slug: z.string().toLowerCase(), pw: queryPasswordSchema }), + ) .query(async (opts) => { if (!opts.input.slug) return null; @@ -1050,6 +1084,8 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + assertPageAccess(opts.ctx, _page, opts.input.pw); + const pageComponents = selectPageComponentWithMonitorRelation .array() .parse(_page.pageComponents); @@ -1166,7 +1202,13 @@ export const statusPageRouter = createTRPCRouter({ }), getMonitor: publicProcedure - .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .input( + z.object({ + slug: z.string().toLowerCase(), + id: z.number(), + pw: queryPasswordSchema, + }), + ) .query(async (opts) => { if (!opts.input.slug) return null; @@ -1184,6 +1226,8 @@ export const statusPageRouter = createTRPCRouter({ if (!_page) return null; + assertPageAccess(opts.ctx, _page, opts.input.pw); + const pageComponents = selectPageComponentWithMonitorRelation .array() .parse(_page.pageComponents); @@ -1304,6 +1348,8 @@ export const statusPageRouter = createTRPCRouter({ }); } + assertPageAccess(opts.ctx, _page); + const workspace = selectWorkspaceSchema.safeParse(_page.workspace); if (!workspace.success) { @@ -1323,6 +1369,8 @@ export const statusPageRouter = createTRPCRouter({ // Guard against email spam: reject if a pending (unverified, unexpired) subscription exists const isPending = await hasPendingSubscriber({ input: { email: opts.input.email, pageId: _page.id }, + // gated by `assertPageAccess` above + visitor: null, }); if (isPending) { throw new TRPCError({ @@ -1340,6 +1388,7 @@ export const statusPageRouter = createTRPCRouter({ ? opts.input.pageComponents : [], }, + visitor: visitorFromCtx(opts.ctx), }); // Already verified — no need to send another verification email diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 3d5b9222..dbc5b7ca 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -33,7 +33,6 @@ import { getPriceIdForPlan, resolveAddonQuantity, } from "./utils"; -import { webhookRouter } from "./webhook"; // The addon `title` reads wrong in the "you already have N ..." sentence. const LIMIT_LABEL: Record = { @@ -47,8 +46,6 @@ const url = : "http://localhost:3000"; export const stripeRouter = createTRPCRouter({ - webhooks: webhookRouter, - getUserCustomerPortal: protectedProcedure .input( z.object({ workspaceSlug: z.string(), returnUrl: z.string().optional() }), diff --git a/packages/api/src/router/stripe/webhook.test.ts b/packages/api/src/router/stripe/webhook.test.ts index 42ea398e..1e449155 100644 --- a/packages/api/src/router/stripe/webhook.test.ts +++ b/packages/api/src/router/stripe/webhook.test.ts @@ -17,10 +17,10 @@ import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; import { assertSpyCalls, type Stub, stub } from "@std/testing/mock"; import type Stripe from "stripe"; -import { lambdaRouter } from "../../lambda"; import { createInnerTRPCContext } from "../../trpc"; import { stripe } from "./shared"; import { PLANS } from "./utils"; +import { webhookRouter } from "./webhook"; const TEAM_PRICE = PLANS.find((p) => p.plan === "team")?.price.monthly.priceIds .test; @@ -65,8 +65,7 @@ function event( } const caller = () => - lambdaRouter.createCaller(createInnerTRPCContext({ session: null })) - .stripeRouter.webhooks; + webhookRouter.createCaller(createInnerTRPCContext({ session: null })); describe("stripe webhook emails", () => { let live: Stripe.Subscription[]; diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index dbcf101a..efae4d45 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -152,6 +152,8 @@ async function sendCancellationEmails(args: { } } +// Never mount this on an app router: the procedures trust `event`, and only +// the signature-verifying HTTP route may call them. export const webhookRouter = createTRPCRouter({ customerSubscriptionUpdated: webhookProcedure.mutation(async (opts) => { const eventSubscription = opts.input.event.data diff --git a/packages/notifications/discord/src/index.ts b/packages/notifications/discord/src/index.ts index 60da593d..fb195c16 100644 --- a/packages/notifications/discord/src/index.ts +++ b/packages/notifications/discord/src/index.ts @@ -4,7 +4,7 @@ import { type NotificationContext, buildCommonMessageData, } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; import { type DiscordEmbed, @@ -18,8 +18,7 @@ const postToWebhook = async (embeds: DiscordEmbed[], webhookUrl: string) => { throw new Error("Discord webhook URL is required"); } - await assertSafeUrl(webhookUrl); - const res = await fetch(webhookUrl, { + const res = await safeFetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/notifications/google-chat/src/index.ts b/packages/notifications/google-chat/src/index.ts index 8d43630d..9d048a24 100644 --- a/packages/notifications/google-chat/src/index.ts +++ b/packages/notifications/google-chat/src/index.ts @@ -1,10 +1,9 @@ import { googleChatDataSchema } from "@openstatus/db/src/schema"; import type { NotificationContext } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; const postToWebhook = async (content: string, webhookUrl: string) => { - await assertSafeUrl(webhookUrl); - const res = await fetch(webhookUrl, { + const res = await safeFetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/notifications/grafana-oncall/src/index.ts b/packages/notifications/grafana-oncall/src/index.ts index 4587486f..00d4e337 100644 --- a/packages/notifications/grafana-oncall/src/index.ts +++ b/packages/notifications/grafana-oncall/src/index.ts @@ -1,5 +1,5 @@ import type { NotificationContext } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; import { GrafanaOncallPayload, GrafanaOncallSchema } from "./schema"; @@ -22,8 +22,7 @@ export const sendAlert = async ({ link_to_upstream_details: `https://www.openstatus.dev/app/${monitor.id}/overview`, }); - await assertSafeUrl(config.webhookUrl); - const res = await fetch(config.webhookUrl, { + const res = await safeFetch(config.webhookUrl, { method: "POST", body: JSON.stringify(event), headers: { @@ -57,8 +56,7 @@ export const sendDegraded = async ({ link_to_upstream_details: `https://www.openstatus.dev/app/${monitor.id}/overview`, }); - await assertSafeUrl(config.webhookUrl); - const res = await fetch(config.webhookUrl, { + const res = await safeFetch(config.webhookUrl, { method: "POST", body: JSON.stringify(event), headers: { @@ -92,8 +90,7 @@ export const sendRecovery = async ({ link_to_upstream_details: `https://www.openstatus.dev/app/${monitor.id}/overview`, }); - await assertSafeUrl(config.webhookUrl); - const res = await fetch(config.webhookUrl, { + const res = await safeFetch(config.webhookUrl, { method: "POST", body: JSON.stringify(event), headers: { @@ -120,9 +117,8 @@ export const sendTest = async (props: { webhookUrl: string }) => { link_to_upstream_details: "https://www.openstatus.dev", }); - await assertSafeUrl(webhookUrl); try { - const res = await fetch(webhookUrl, { + const res = await safeFetch(webhookUrl, { method: "POST", body: JSON.stringify(event), headers: { diff --git a/packages/notifications/ms-teams/src/index.ts b/packages/notifications/ms-teams/src/index.ts index e6439114..cf7013c4 100644 --- a/packages/notifications/ms-teams/src/index.ts +++ b/packages/notifications/ms-teams/src/index.ts @@ -3,7 +3,7 @@ import { type NotificationContext, buildCommonMessageData, } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; import { type AdaptiveCard, @@ -21,9 +21,7 @@ const postCard = async ( throw new Error("Microsoft Teams webhook URL is required"); } - await assertSafeUrl(webhookUrl); - - const res = await fetch(webhookUrl, { + const res = await safeFetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/packages/notifications/ntfy/src/index.ts b/packages/notifications/ntfy/src/index.ts index a6dbe448..7b6a2b37 100644 --- a/packages/notifications/ntfy/src/index.ts +++ b/packages/notifications/ntfy/src/index.ts @@ -1,6 +1,6 @@ import { ntfyDataSchema } from "@openstatus/db/src/schema"; import type { NotificationContext } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; export const sendAlert = async ({ monitor, @@ -23,8 +23,7 @@ export const sendAlert = async ({ ? `${notificationData.ntfy.serverUrl}/${notificationData.ntfy.topic}` : `https://ntfy.sh/${notificationData.ntfy.topic}`; - await assertSafeUrl(url); - const res = await fetch(url, { + const res = await safeFetch(url, { method: "post", body, headers: { @@ -54,8 +53,7 @@ export const sendRecovery = async ({ ? `${notificationData.ntfy.serverUrl}/${notificationData.ntfy.topic}` : `https://ntfy.sh/${notificationData.ntfy.topic}`; - await assertSafeUrl(url); - const res = await fetch(url, { + const res = await safeFetch(url, { method: "post", body, headers: { @@ -86,8 +84,7 @@ export const sendDegraded = async ({ ? `${notificationData.ntfy.serverUrl}/${notificationData.ntfy.topic}` : `https://ntfy.sh/${notificationData.ntfy.topic}`; - await assertSafeUrl(url); - const res = await fetch(url, { + const res = await safeFetch(url, { method: "post", body, headers: { @@ -114,15 +111,17 @@ export const sendTest = async ({ ? { Authorization: `Bearer ${token}` } : undefined; const url = serverUrl ? `${serverUrl}/${topic}` : `https://ntfy.sh/${topic}`; - await assertSafeUrl(url); try { - await fetch(url, { + const res = await safeFetch(url, { method: "post", body: "This is a test message from OpenStatus", headers: { ...authorization, }, }); + if (!res.ok) { + throw new Error(`Failed to send test: ${res.status} ${res.statusText}`); + } } catch (err) { console.log(err); return false; diff --git a/packages/notifications/slack/src/index.ts b/packages/notifications/slack/src/index.ts index a58f0cf6..e68f6659 100644 --- a/packages/notifications/slack/src/index.ts +++ b/packages/notifications/slack/src/index.ts @@ -4,7 +4,7 @@ import { type NotificationContext, buildCommonMessageData, } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { safeFetch } from "@openstatus/utils"; import { buildAlertBlocks, @@ -20,8 +20,7 @@ const postToWebhook = async ( throw new Error("Slack webhook URL is required"); } - await assertSafeUrl(webhookUrl); - const res = await fetch(webhookUrl, { + const res = await safeFetch(webhookUrl, { method: "POST", body: JSON.stringify(body), }); diff --git a/packages/notifications/webhook/src/index.ts b/packages/notifications/webhook/src/index.ts index 3a8ab03f..e651133f 100644 --- a/packages/notifications/webhook/src/index.ts +++ b/packages/notifications/webhook/src/index.ts @@ -1,5 +1,5 @@ import type { NotificationContext } from "@openstatus/notification-base"; -import { assertSafeUrl, transformHeaders } from "@openstatus/utils"; +import { safeFetch, transformHeaders } from "@openstatus/utils"; import { PayloadSchema, WebhookSchema } from "./schema"; @@ -22,8 +22,7 @@ export const sendAlert = async ({ errorMessage: message, }); - await assertSafeUrl(notificationData.webhook.endpoint); - const res = await fetch(notificationData.webhook.endpoint, { + const res = await safeFetch(notificationData.webhook.endpoint, { method: "post", body: JSON.stringify(body), headers: { @@ -55,8 +54,7 @@ export const sendRecovery = async ({ errorMessage: message, }); const url = notificationData.webhook.endpoint; - await assertSafeUrl(url); - const res = await fetch(url, { + const res = await safeFetch(url, { method: "post", body: JSON.stringify(body), headers: { @@ -88,8 +86,7 @@ export const sendDegraded = async ({ errorMessage: message, }); - await assertSafeUrl(notificationData.webhook.endpoint); - const res = await fetch(notificationData.webhook.endpoint, { + const res = await safeFetch(notificationData.webhook.endpoint, { method: "post", body: JSON.stringify(body), headers: { @@ -120,9 +117,8 @@ export const sendTest = async ({ statusCode: 200, latency: 1337, }); - await assertSafeUrl(url); try { - const response = await fetch(url, { + const response = await safeFetch(url, { method: "post", body: JSON.stringify(body), headers: { diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md index 51e4009c..8822dd6c 100644 --- a/packages/services/AGENTS.md +++ b/packages/services/AGENTS.md @@ -53,6 +53,15 @@ read. `requireScope` is a no-op for `user` / `system` / `slack` / `webhook` / `scope` and register through `registerScopedTool`, so read-only keys never see write tools. +## Status-page visitor access + +`src/page-access` decides whether an anonymous visitor may see a gated page +(password, email domain, IP range). It is pure — the transport builds a +`PageVisitor` — and the status-page proxy imports it, so keep it Edge-safe. +A verb reachable by visitors takes a `visitor` argument and calls +`assertPageAccess`; `null` is only for callers that already authorized the +request inside their own workspace. + ## Runtime constraints - **No `node:*` imports.** `apps/workflows` runs this code on Deno, and the diff --git a/packages/services/package.json b/packages/services/package.json index f7ad2b7a..10e407a7 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -113,6 +113,14 @@ "import": "./src/private-location/index.ts", "types": "./src/private-location/index.ts" }, + "./page-access": { + "import": "./src/page-access/index.ts", + "types": "./src/page-access/index.ts" + }, + "./page-access/cookie-key": { + "import": "./src/page-access/cookie-key.ts", + "types": "./src/page-access/cookie-key.ts" + }, "./page-subscriber": { "import": "./src/page-subscriber/index.ts", "types": "./src/page-subscriber/index.ts" @@ -159,6 +167,7 @@ "@openstatus/utils": "workspace:*", "@workos-inc/node": "catalog:", "effect": "catalog:", + "ip-cidr": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/services/src/notification/index.ts b/packages/services/src/notification/index.ts index 0b0bd55f..3b57aab0 100644 --- a/packages/services/src/notification/index.ts +++ b/packages/services/src/notification/index.ts @@ -1,5 +1,6 @@ export { createNotification } from "./create"; export { deleteNotification } from "./delete"; +export { assertProviderAllowed } from "./internal"; export { getNotification, type ListNotificationsResult, diff --git a/apps/status-page/src/lib/proxy/access-predicates.test.ts b/packages/services/src/page-access/access-predicates.test.ts similarity index 100% rename from apps/status-page/src/lib/proxy/access-predicates.test.ts rename to packages/services/src/page-access/access-predicates.test.ts diff --git a/apps/status-page/src/lib/proxy/access-predicates.ts b/packages/services/src/page-access/access-predicates.ts similarity index 90% rename from apps/status-page/src/lib/proxy/access-predicates.ts rename to packages/services/src/page-access/access-predicates.ts index a73c70cc..be23719e 100644 --- a/apps/status-page/src/lib/proxy/access-predicates.ts +++ b/packages/services/src/page-access/access-predicates.ts @@ -8,10 +8,8 @@ import { isIpAllowed } from "./is-ip-allowed"; */ // Length-independent comparison so a wrong guess can't be timed by length or -// character. Pure JS (no node:crypto): must be Edge-safe for the proxy, so it -// can't import the twin in packages/api (not in the middleware bundle) — hence -// the duplication; keep both implementations in sync. -function constantTimeEqual( +// character. Pure JS (no node:crypto): the status-page proxy imports this. +export function constantTimeEqual( a: string | null | undefined, b: string | null | undefined, ): boolean { diff --git a/apps/status-page/src/lib/http/client-ip.test.ts b/packages/services/src/page-access/client-ip.test.ts similarity index 100% rename from apps/status-page/src/lib/http/client-ip.test.ts rename to packages/services/src/page-access/client-ip.test.ts diff --git a/apps/status-page/src/lib/http/client-ip.ts b/packages/services/src/page-access/client-ip.ts similarity index 100% rename from apps/status-page/src/lib/http/client-ip.ts rename to packages/services/src/page-access/client-ip.ts diff --git a/packages/services/src/page-access/cookie-key.ts b/packages/services/src/page-access/cookie-key.ts new file mode 100644 index 00000000..c5455808 --- /dev/null +++ b/packages/services/src/page-access/cookie-key.ts @@ -0,0 +1,2 @@ +// Dependency-free on purpose: status-page client components import this. +export const pageAccessCookieKey = (slug: string) => `secured-${slug}`; diff --git a/apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts b/packages/services/src/page-access/evaluate-markdown-gate.test.ts similarity index 100% rename from apps/status-page/src/lib/proxy/evaluate-markdown-gate.test.ts rename to packages/services/src/page-access/evaluate-markdown-gate.test.ts diff --git a/apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts b/packages/services/src/page-access/evaluate-markdown-gate.ts similarity index 100% rename from apps/status-page/src/lib/proxy/evaluate-markdown-gate.ts rename to packages/services/src/page-access/evaluate-markdown-gate.ts diff --git a/packages/services/src/page-access/index.ts b/packages/services/src/page-access/index.ts new file mode 100644 index 00000000..7264c2c4 --- /dev/null +++ b/packages/services/src/page-access/index.ts @@ -0,0 +1,18 @@ +export { + constantTimeEqual, + isEmailDomainAuthorized, + isIpAuthorized, + isPasswordAuthorized, +} from "./access-predicates"; +export { resolveClientIp } from "./client-ip"; +export { pageAccessCookieKey } from "./cookie-key"; +export { + evaluateMarkdownGate, + type MarkdownGateResult, +} from "./evaluate-markdown-gate"; +export { isIpAllowed } from "./is-ip-allowed"; +export { + assertPageAccess, + type PageVisitor, + resolvePageAccess, +} from "./resolve-page-access"; diff --git a/apps/status-page/src/lib/proxy/is-ip-allowed.test.ts b/packages/services/src/page-access/is-ip-allowed.test.ts similarity index 100% rename from apps/status-page/src/lib/proxy/is-ip-allowed.test.ts rename to packages/services/src/page-access/is-ip-allowed.test.ts diff --git a/apps/status-page/src/lib/proxy/is-ip-allowed.ts b/packages/services/src/page-access/is-ip-allowed.ts similarity index 100% rename from apps/status-page/src/lib/proxy/is-ip-allowed.ts rename to packages/services/src/page-access/is-ip-allowed.ts diff --git a/packages/services/src/page-access/resolve-page-access.ts b/packages/services/src/page-access/resolve-page-access.ts new file mode 100644 index 00000000..623e642c --- /dev/null +++ b/packages/services/src/page-access/resolve-page-access.ts @@ -0,0 +1,63 @@ +import { type page, selectPageSchema } from "@openstatus/db/src/schema"; + +import { ForbiddenError, UnauthorizedError } from "../errors"; +import { isPasswordAuthorized } from "./access-predicates"; +import { pageAccessCookieKey } from "./cookie-key"; +import { + evaluateMarkdownGate, + type MarkdownGateResult, +} from "./evaluate-markdown-gate"; + +const accessFieldsSchema = selectPageSchema.pick({ + slug: true, + accessType: true, + password: true, + authEmailDomains: true, + allowedIpRanges: true, +}); + +type AccessRow = Pick< + typeof page.$inferSelect, + keyof typeof accessFieldsSchema.shape +>; + +/** What an anonymous status-page visitor presented, extracted by the transport. */ +export type PageVisitor = { + // A getter: only the gate knows the page slug the cookie is keyed on. + getCookie?: (name: string) => string | null | undefined; + queryPassword?: string | null; + email?: string | null; + clientIp?: string | null; +}; + +export function resolvePageAccess( + row: AccessRow, + visitor: PageVisitor, +): MarkdownGateResult { + const parsed = accessFieldsSchema.safeParse(row); + if (!parsed.success) return { ok: false, status: 403, body: "Forbidden" }; + const _page = parsed.data; + + return evaluateMarkdownGate({ + accessType: _page.accessType, + passwordAuthorized: + _page.accessType === "password" && + isPasswordAuthorized({ + stored: _page.password, + queryPassword: visitor.queryPassword, + cookiePassword: visitor.getCookie?.(pageAccessCookieKey(_page.slug)), + }), + authEmail: visitor.email, + authEmailDomains: _page.authEmailDomains, + clientIp: visitor.clientIp, + allowedIpRanges: _page.allowedIpRanges, + }); +} + +export function assertPageAccess(row: AccessRow, visitor: PageVisitor): void { + const gate = resolvePageAccess(row, visitor); + if (gate.ok) return; + throw gate.status === 401 + ? new UnauthorizedError("Page access denied.") + : new ForbiddenError("Page access denied."); +} diff --git a/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts b/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts index 7fce9a0d..5b8e510a 100644 --- a/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts +++ b/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts @@ -27,7 +27,7 @@ import { readAuditLog, withTestTransaction, } from "../../../test/helpers"; -import { ForbiddenError } from "../../errors"; +import { ForbiddenError, UnauthorizedError } from "../../errors"; import { createPageSubscriber, getSubscriberByToken, @@ -67,6 +67,7 @@ const EMAILS = { unsubWorkspaceId: "svc-unsub-ws-id-test@example.com", unsubWorkspaceDenied: "svc-unsub-ws-denied-test@example.com", hasPending: "svc-has-pending-test@example.com", + visitorGate: "svc-visitor-gate-test@example.com", }; async function cleanAll() { @@ -105,6 +106,7 @@ describe("upsertSelfSignupSubscriber", () => { test("creates a new subscription for an unknown email", async () => { const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); @@ -129,6 +131,7 @@ describe("upsertSelfSignupSubscriber", () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, fresh)); const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); @@ -148,7 +151,10 @@ describe("upsertSelfSignupSubscriber", () => { }); test("does not create a duplicate row when called again", async () => { - await upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + await upsertSelfSignupSubscriber({ + visitor: null, + input: { email, pageId: PAGE_ID }, + }); const rows = await db.query.pageSubscriber.findMany({ where: eq(pageSubscriber.email, email), }); @@ -157,6 +163,7 @@ describe("upsertSelfSignupSubscriber", () => { test("merges new components into an existing pending subscription", async () => { const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID, componentIds: [COMPONENT_1] }, }); expect(result.componentIds).toContain(COMPONENT_1); @@ -187,7 +194,10 @@ describe("upsertSelfSignupSubscriber", () => { test("refreshes expiresAt for a still-pending subscription", async () => { const before = new Date(); - await upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + await upsertSelfSignupSubscriber({ + visitor: null, + input: { email, pageId: PAGE_ID }, + }); const row = await db.query.pageSubscriber.findFirst({ where: eq(pageSubscriber.email, email), }); @@ -198,6 +208,7 @@ describe("upsertSelfSignupSubscriber", () => { const fresh = EMAILS.upsertCase; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, fresh)); const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh.toUpperCase(), pageId: PAGE_ID }, }); expect(result.email).toBe(fresh); @@ -207,6 +218,7 @@ describe("upsertSelfSignupSubscriber", () => { const fresh = EMAILS.upsertReactivate; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, fresh)); const initial = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); await db @@ -216,6 +228,7 @@ describe("upsertSelfSignupSubscriber", () => { .run(); const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID, componentIds: [COMPONENT_1] }, }); @@ -233,6 +246,7 @@ describe("upsertSelfSignupSubscriber", () => { const fresh = EMAILS.upsertPendingThenUnsub; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, fresh)); const pending = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); await db @@ -242,6 +256,7 @@ describe("upsertSelfSignupSubscriber", () => { .run(); const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); @@ -259,6 +274,7 @@ describe("upsertSelfSignupSubscriber", () => { const fresh = "svc-upsert-already-verified@example.com"; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, fresh)); const initial = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); await db @@ -278,6 +294,7 @@ describe("upsertSelfSignupSubscriber", () => { ); const result = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: fresh, pageId: PAGE_ID }, }); expect(result.id).toBe(initial.id); @@ -296,6 +313,7 @@ describe("upsertSelfSignupSubscriber", () => { test("throws for component IDs that do not belong to this page", async () => { await expect( upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID, componentIds: [9999] }, }), ).rejects.toThrow("Some components do not belong to this page"); @@ -304,6 +322,7 @@ describe("upsertSelfSignupSubscriber", () => { test("throws for a page ID that does not exist", async () => { await expect( upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: 99999 }, }), ).rejects.toThrow(); @@ -327,6 +346,7 @@ describe("upsertSelfSignupSubscriber", () => { .get(); await expect( upsertSelfSignupSubscriber({ + visitor: null, input: { email: EMAILS.planGate, pageId: freePage.id }, db: tx, }), @@ -345,6 +365,7 @@ describe("verifySelfSignupSubscriber", () => { beforeAll(async () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -441,6 +462,7 @@ describe("getSubscriberByToken", () => { beforeAll(async () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -498,6 +520,7 @@ describe("updateSubscriberScope", () => { beforeAll(async () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID, componentIds: [COMPONENT_1] }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -530,6 +553,7 @@ describe("updateSubscriberScope", () => { .delete(pageSubscriber) .where(eq(pageSubscriber.email, EMAILS.scopeUnverified)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: EMAILS.scopeUnverified, pageId: PAGE_ID }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -546,6 +570,7 @@ describe("updateSubscriberScope", () => { .delete(pageSubscriber) .where(eq(pageSubscriber.email, EMAILS.scopeUnsubbed)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email: EMAILS.scopeUnsubbed, pageId: PAGE_ID }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -618,6 +643,7 @@ describe("unsubscribeSubscriber", () => { beforeAll(async () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); if (!sub.token) throw new Error("Token is undefined"); @@ -705,6 +731,7 @@ describe("hasPendingSubscriber", () => { test("returns false when no row exists", async () => { const result = await hasPendingSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); expect(result).toBe(false); @@ -712,9 +739,11 @@ describe("hasPendingSubscriber", () => { test("returns true for a pending unexpired row", async () => { await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); const result = await hasPendingSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); expect(result).toBe(true); @@ -722,6 +751,7 @@ describe("hasPendingSubscriber", () => { test("returns false for a pending row whose expiresAt has passed", async () => { const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); await db @@ -731,6 +761,7 @@ describe("hasPendingSubscriber", () => { .run(); const result = await hasPendingSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); expect(result).toBe(false); @@ -738,6 +769,7 @@ describe("hasPendingSubscriber", () => { test("returns false for an already-verified (accepted) row", async () => { const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); await db @@ -747,6 +779,7 @@ describe("hasPendingSubscriber", () => { .run(); const result = await hasPendingSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); expect(result).toBe(false); @@ -754,6 +787,7 @@ describe("hasPendingSubscriber", () => { test("returns false for an unsubscribed row", async () => { const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); await db @@ -763,6 +797,7 @@ describe("hasPendingSubscriber", () => { .run(); const result = await hasPendingSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); expect(result).toBe(false); @@ -814,7 +849,10 @@ describe("unsubscribePageSubscriber", () => { async function seed(email: string) { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); - return upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + return upsertSelfSignupSubscriber({ + visitor: null, + input: { email, pageId: PAGE_ID }, + }); } test("rejects read-only actor", async () => { @@ -903,6 +941,7 @@ describe("unsubscribePageSubscriber by id", () => { const email = "svc-unsub-ws-byid-test@example.com"; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); @@ -929,7 +968,10 @@ describe("unsubscribePageSubscriber by id", () => { // call must not silently succeed against a stale row. const email = "svc-unsub-ws-stale-test@example.com"; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); - await upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + await upsertSelfSignupSubscriber({ + visitor: null, + input: { email, pageId: PAGE_ID }, + }); const ctx = makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }); const input = { pageId: PAGE_ID, @@ -948,6 +990,7 @@ describe("unsubscribePageSubscriber by id", () => { const email = "svc-unsub-ws-byid-repeat-test@example.com"; await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); const sub = await upsertSelfSignupSubscriber({ + visitor: null, input: { email, pageId: PAGE_ID }, }); const ctx = makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }); @@ -981,3 +1024,64 @@ describe("unsubscribePageSubscriber by id", () => { await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); }); }); + +describe("self-signup visitor gate", () => { + const email = EMAILS.visitorGate; + const password = "svc-gate-pw"; + let gatedPageId: number; + let gatedSlug: string; + + beforeAll(async () => { + const p = await createPage(WORKSPACE_ID, { + accessType: "password", + password, + }); + gatedPageId = p.id; + gatedSlug = p.slug; + }); + + test("rejects a visitor without the page password", async () => { + await expect( + upsertSelfSignupSubscriber({ + visitor: {}, + input: { email, pageId: gatedPageId }, + }), + ).rejects.toBeInstanceOf(UnauthorizedError); + await expect( + hasPendingSubscriber({ + visitor: { queryPassword: "wrong" }, + input: { email, pageId: gatedPageId }, + }), + ).rejects.toBeInstanceOf(UnauthorizedError); + + const rows = await db + .select() + .from(pageSubscriber) + .where(eq(pageSubscriber.email, email)); + expect(rows.length).toBe(0); + }); + + test("an empty query password does not fall through to a valid cookie", async () => { + await expect( + upsertSelfSignupSubscriber({ + visitor: { queryPassword: "", getCookie: () => password }, + input: { email, pageId: gatedPageId }, + }), + ).rejects.toBeInstanceOf(UnauthorizedError); + }); + + test("accepts a visitor holding the password cookie", async () => { + const seen: string[] = []; + const result = await upsertSelfSignupSubscriber({ + visitor: { + getCookie: (name) => { + seen.push(name); + return password; + }, + }, + input: { email, pageId: gatedPageId }, + }); + expect(result.email).toBe(email); + expect(seen).toEqual([`secured-${gatedSlug}`]); + }); +}); diff --git a/packages/services/src/page-subscriber/create.ts b/packages/services/src/page-subscriber/create.ts index 6eb1d5b1..22cf8317 100644 --- a/packages/services/src/page-subscriber/create.ts +++ b/packages/services/src/page-subscriber/create.ts @@ -58,9 +58,8 @@ export async function createPageSubscriber(args: { const input = CreatePageSubscriberInput.parse(args.input); const componentIds = input.componentIds ?? []; - // Webhook URL pre-checks happen outside the tx. `assertSafeUrl` does - // a DNS resolution to block private/internal targets — keeping it - // outside avoids holding the SQLite write lock across a network call. + // String-only check (no DNS resolution) — a public name pointing at a + // private address still passes. Delivery and test sends never follow redirects. if (input.channelType === "webhook") { await assertSafeUrl(input.webhookUrl); } diff --git a/packages/services/src/page-subscriber/has-pending.ts b/packages/services/src/page-subscriber/has-pending.ts index b65d9d8d..4bb498cb 100644 --- a/packages/services/src/page-subscriber/has-pending.ts +++ b/packages/services/src/page-subscriber/has-pending.ts @@ -1,7 +1,9 @@ import { and, eq, isNull } from "@openstatus/db"; -import { pageSubscriber } from "@openstatus/db/src/schema"; +import { page, pageSubscriber } from "@openstatus/db/src/schema"; import { type DB, type ServiceContext, getReadDb } from "../context"; +import { NotFoundError } from "../errors"; +import { type PageVisitor, assertPageAccess } from "../page-access"; import { HasPendingSubscriberInput } from "./schemas"; /** @@ -12,11 +14,22 @@ import { HasPendingSubscriberInput } from "./schemas"; */ export async function hasPendingSubscriber(args: { input: HasPendingSubscriberInput; + /** `null` only when the caller already ran the page gate. */ + visitor: PageVisitor | null; db?: DB; }): Promise { const input = HasPendingSubscriberInput.parse(args.input); const db = getReadDb({ db: args.db } as ServiceContext); + // Gate first: pending state on a protected page is not public. + if (args.visitor) { + const pageData = await db.query.page.findFirst({ + where: eq(page.id, input.pageId), + }); + if (!pageData) throw new NotFoundError("page", input.pageId); + assertPageAccess(pageData, args.visitor); + } + const existing = await db.query.pageSubscriber.findFirst({ where: and( eq(pageSubscriber.email, input.email.toLowerCase()), diff --git a/packages/services/src/page-subscriber/update.ts b/packages/services/src/page-subscriber/update.ts index 4beb9413..ad5b128f 100644 --- a/packages/services/src/page-subscriber/update.ts +++ b/packages/services/src/page-subscriber/update.ts @@ -37,9 +37,8 @@ export async function updatePageSubscriberChannel(args: { requireScope(ctx, "write"); const input = UpdatePageSubscriberChannelInput.parse(args.input); - // `assertSafeUrl` does a DNS lookup to block private/internal targets; - // keep it outside the tx so we don't hold the SQLite write lock across - // a network call. + // String-only check (no DNS resolution) — a public name pointing at a + // private address still passes. Delivery and test sends never follow redirects. if (input.webhookUrl !== undefined) { await assertSafeUrl(input.webhookUrl); } diff --git a/packages/services/src/page-subscriber/upsert.ts b/packages/services/src/page-subscriber/upsert.ts index b2710b08..2e0425bf 100644 --- a/packages/services/src/page-subscriber/upsert.ts +++ b/packages/services/src/page-subscriber/upsert.ts @@ -15,6 +15,7 @@ import { withTransaction, } from "../context"; import { NotFoundError, ValidationError } from "../errors"; +import { type PageVisitor, assertPageAccess } from "../page-access"; import { assertSubscribersAllowed, parseWorkspaceForContext } from "./internal"; import { UpsertSelfSignupSubscriberInput } from "./schemas"; @@ -54,6 +55,8 @@ export type UpsertSelfSignupResult = { // oxlint-disable-next-line openstatus/services-mutation-guards export async function upsertSelfSignupSubscriber(args: { input: UpsertSelfSignupSubscriberInput; + /** `null` only when the caller already authorized the request (workspace API). */ + visitor: PageVisitor | null; db?: DB; }): Promise { const input = UpsertSelfSignupSubscriberInput.parse(args.input); @@ -67,6 +70,7 @@ export async function upsertSelfSignupSubscriber(args: { if (!pageData) { throw new NotFoundError("page", input.pageId); } + if (args.visitor) assertPageAccess(pageData, args.visitor); const workspace = parseWorkspaceForContext(pageData.workspace); // Plan-gate before any DB writes — same upsell semantics as the // dashboard `createPageSubscriber` path. Free-plan pages don't diff --git a/packages/services/src/page/index.ts b/packages/services/src/page/index.ts index 83f67cd1..8bc4fabf 100644 --- a/packages/services/src/page/index.ts +++ b/packages/services/src/page/index.ts @@ -7,6 +7,7 @@ export { getStatusPageContent, } from "./get-content"; export { + assertCustomDomainInWorkspace, getPage, getPageBySlug, getPageCustomDomain, diff --git a/packages/services/src/page/list.ts b/packages/services/src/page/list.ts index 8defa684..5551d612 100644 --- a/packages/services/src/page/list.ts +++ b/packages/services/src/page/list.ts @@ -167,6 +167,26 @@ export async function getPageCustomDomain(args: { return row.customDomain; } +/** NotFound rather than Forbidden so a miss is no existence oracle. */ +export async function assertCustomDomainInWorkspace(args: { + ctx: ServiceContext; + input: { domain: string }; +}): Promise { + const { ctx, input } = args; + const row = await getReadDb(ctx) + .select({ id: page.id }) + .from(page) + .where( + and( + sql`lower(${page.customDomain}) = ${input.domain.toLowerCase()}`, + eq(page.workspaceId, ctx.workspace.id), + ), + ) + .get(); + + if (!row) throw new NotFoundError("domain"); +} + /** * Cross-workspace lookup of a page by slug. Returns the raw row (not parsed * via `selectPageSchema`) because callers in the public status-page render diff --git a/packages/subscriptions/src/channels/retry.ts b/packages/subscriptions/src/channels/retry.ts index 41e37180..a6cf5359 100644 --- a/packages/subscriptions/src/channels/retry.ts +++ b/packages/subscriptions/src/channels/retry.ts @@ -1,3 +1,4 @@ +import { safeFetch } from "@openstatus/utils"; import { Effect, Schedule } from "effect"; export class WebhookSendError extends Error { @@ -20,6 +21,11 @@ const isRetryable = (err: WebhookSendError): boolean => err.httpStatus >= 500 || err.httpStatus === 429; +const describeFailure = (status: number) => + status >= 300 && status < 400 + ? `Webhook redirected (${status}); only 307/308 to the same host are followed` + : `Webhook returned ${status}`; + const retryPolicy = { schedule: Schedule.exponential("200 millis").pipe(Schedule.jittered), times: 3, @@ -38,7 +44,7 @@ export function postWebhookWithRetry(opts: { }): Promise { const send = Effect.tryPromise({ try: (signal) => - fetch(opts.url, { + safeFetch(opts.url, { method: "POST", headers: opts.headers, body: opts.body, @@ -57,7 +63,7 @@ export function postWebhookWithRetry(opts: { response.ok ? Effect.void : Effect.fail( - new WebhookSendError(`Webhook returned ${response.status}`, { + new WebhookSendError(describeFailure(response.status), { httpStatus: response.status, }), ), diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts index 13cb72e5..cc17dedb 100644 --- a/packages/subscriptions/src/channels/webhook.ts +++ b/packages/subscriptions/src/channels/webhook.ts @@ -1,5 +1,5 @@ import { COLORS, COLOR_DECIMALS } from "@openstatus/notification-base"; -import { assertSafeUrl, statusLabel } from "@openstatus/utils"; +import { assertSafeUrl, safeFetch, statusLabel } from "@openstatus/utils"; import { z } from "zod"; import { WEBHOOK_PAYLOAD_VERSION } from "../payload"; @@ -104,8 +104,7 @@ export async function sendWebhookVerification( throw new Error("Webhook URL is required for webhook channel"); } - await assertSafeUrl(subscription.webhookUrl); - const response = await fetch(subscription.webhookUrl, { + const response = await safeFetch(subscription.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -477,7 +476,6 @@ export async function sendTestWebhookRequest(input: { headers?: Record; }) { const { url, flavor, headers: extraHeaders = {} } = input; - await assertSafeUrl(url); const headers: Record = { "Content-Type": "application/json", @@ -485,7 +483,7 @@ export async function sendTestWebhookRequest(input: { ...extraHeaders, }; - const response = await fetch(url, { + const response = await safeFetch(url, { method: "POST", headers, body: JSON.stringify(buildTestPayload(flavor)), diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 70771944..4caa8f75 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,9 @@ -export { assertSafeUrl, assertSafeUrlSync, safeUrlSchema } from "./ssrf"; +export { + assertSafeUrl, + assertSafeUrlSync, + safeFetch, + safeUrlSchema, +} from "./ssrf"; export { type DNSPayload, DNSPayloadSchema, diff --git a/packages/utils/src/ssrf.test.ts b/packages/utils/src/ssrf.test.ts index e687e0e9..587e3de8 100644 --- a/packages/utils/src/ssrf.test.ts +++ b/packages/utils/src/ssrf.test.ts @@ -1,7 +1,12 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; -import { assertSafeUrl, assertSafeUrlSync, safeUrlSchema } from "./ssrf"; +import { + assertSafeUrl, + assertSafeUrlSync, + safeFetch, + safeUrlSchema, +} from "./ssrf"; // --- assertSafeUrlSync (no DNS, used in Zod schemas) --- @@ -138,7 +143,7 @@ describe("assertSafeUrlSync", () => { }); }); -// --- assertSafeUrl (async, with DNS resolution) --- +// --- assertSafeUrl (async, same string-only checks) --- describe("assertSafeUrl", () => { it("allows a valid public URL", async () => { @@ -214,3 +219,120 @@ describe("safeUrlSchema", () => { expect(result.success).toBe(false); }); }); + +describe("safeFetch", () => { + it("rejects a private target without fetching", async () => { + const original = globalThis.fetch; + let called = false; + globalThis.fetch = () => { + called = true; + return Promise.resolve(new Response()); + }; + try { + await expect( + safeFetch("http://169.254.169.254/latest/meta-data"), + ).rejects.toThrow(); + expect(called).toBe(false); + } finally { + globalThis.fetch = original; + } + }); + + it("does not follow a 302", async () => { + const original = globalThis.fetch; + let seen: RequestInit | undefined; + globalThis.fetch = (_input, init) => { + seen = init; + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/" }, + }), + ); + }; + try { + const res = await safeFetch("https://example.com/hook", { + method: "POST", + }); + expect(seen?.redirect).toBe("manual"); + expect(res.ok).toBe(false); + } finally { + globalThis.fetch = original; + } + }); + + function redirecting(hops: Record) { + const seen: string[] = []; + const original = globalThis.fetch; + globalThis.fetch = (input) => { + const url = String(input); + seen.push(url); + const hop = hops[url]; + return Promise.resolve( + hop + ? new Response(null, { + status: hop[0], + headers: { location: hop[1] }, + }) + : new Response("ok"), + ); + }; + return { seen, restore: () => (globalThis.fetch = original) }; + } + + it("follows a same-host 307/308, e.g. an http to https upgrade", async () => { + const { seen, restore } = redirecting({ + "http://example.com/hook": [308, "https://example.com/hook"], + "https://example.com/hook": [307, "/v2/hook"], + }); + try { + const res = await safeFetch("http://example.com/hook", { + method: "POST", + }); + expect(res.ok).toBe(true); + expect(seen).toEqual([ + "http://example.com/hook", + "https://example.com/hook", + "https://example.com/v2/hook", + ]); + } finally { + restore(); + } + }); + + it("does not follow a 307 that would replay headers elsewhere", async () => { + for (const location of [ + "https://evil.example/hook", + "http://169.254.169.254/latest/meta-data", + // downgrade to plaintext + "http://example.com/hook", + // same host, different service + "https://example.com:8443/hook", + ]) { + const { seen, restore } = redirecting({ + "https://example.com/hook": [307, location], + }); + try { + const res = await safeFetch("https://example.com/hook"); + expect(res.ok).toBe(false); + expect(seen).toEqual(["https://example.com/hook"]); + } finally { + restore(); + } + } + }); + + it("stops after a bounded number of hops", async () => { + const { seen, restore } = redirecting({ + "https://example.com/a": [307, "/b"], + "https://example.com/b": [307, "/a"], + }); + try { + const res = await safeFetch("https://example.com/a"); + expect(res.ok).toBe(false); + expect(seen.length).toBe(4); + } finally { + restore(); + } + }); +}); diff --git a/packages/utils/src/ssrf.ts b/packages/utils/src/ssrf.ts index 34ca534a..29838a23 100644 --- a/packages/utils/src/ssrf.ts +++ b/packages/utils/src/ssrf.ts @@ -111,6 +111,58 @@ export async function assertSafeUrl(urlString: string): Promise { } } +const MAX_REDIRECTS = 3; + +function isSameService(from: URL, to: URL): boolean { + return ( + to.hostname === from.hostname && + to.port === from.port && + (to.protocol === from.protocol || + (from.protocol === "http:" && to.protocol === "https:")) + ); +} + +function isSafeRedirect(from: URL, to: URL): boolean { + return ( + to.hostname === from.hostname && + to.port === from.port && + (to.protocol === from.protocol || + (from.protocol === "http:" && to.protocol === "https:")) + ); +} + +/** + * `fetch` for customer-supplied URLs. Follows only 307/308 (the redirects that + * keep method and body) to the same host and port, never downgrading https, + * re-checking every hop; any other 3xx comes back as a non-ok response. + * `init.body` must be replayable. + */ +export async function safeFetch( + url: string, + init?: Omit, +): Promise { + let target = url; + for (let hop = 0; ; hop++) { + await assertSafeUrl(target); + const res = await fetch(target, { ...init, redirect: "manual" }); + + const location = res.headers.get("location"); + if ((res.status !== 307 && res.status !== 308) || !location) return res; + + // Headers are replayed on the next hop, so it must be the same service and + // never plaintext: same host and port, protocol unchanged or http → https. + if ( + hop >= MAX_REDIRECTS || + !isSafeRedirect(new URL(target), new URL(location, target)) + ) { + return res; + } + const next = new URL(location, target); + await res.body?.cancel(); + target = next.href; + } +} + /** * Synchronous URL safety check for use in Zod schemas. * Checks protocol and hostname/IP without DNS resolution. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acaf9c0f..7eaed2e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1324,6 +1324,9 @@ importers: '@openstatus/react': specifier: workspace:* version: link:../../packages/react + '@openstatus/services': + specifier: workspace:* + version: link:../../packages/services '@openstatus/theme-store': specifier: workspace:* version: link:../../packages/theme-store @@ -2917,6 +2920,9 @@ importers: effect: specifier: 'catalog:' version: 4.0.0-rc.112 + ip-cidr: + specifier: 'catalog:' + version: 4.0.2 zod: specifier: 'catalog:' version: 4.1.13