From 977067fe715d290f971a87a61464beb749afa3f5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 31 May 2026 20:54:42 -0700 Subject: [PATCH 1/3] fix(api): enforce chat session ownership on /api/chat routes (IDOR) (#5017) The /api/chat/[sessionId] and /api/chat/[sessionId]/stream handlers only checked that the caller was authenticated, never that they owned the session. Any logged-in user could read (GET), inject into (POST), rename (PATCH), or delete (DELETE) another organization's chat session by id. Scope every handler by createdBy (matching the secure tRPC chatRouter), returning 404 on a non-owned session so session ids aren't enumerable. --- .../api/src/app/api/chat/[sessionId]/route.ts | 21 ++++++++++++--- .../app/api/chat/[sessionId]/stream/route.ts | 26 +++++++++++++++++-- apps/api/src/app/api/chat/lib.ts | 23 ++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/api/chat/[sessionId]/route.ts b/apps/api/src/app/api/chat/[sessionId]/route.ts index 8163787cc..f80eccf60 100644 --- a/apps/api/src/app/api/chat/[sessionId]/route.ts +++ b/apps/api/src/app/api/chat/[sessionId]/route.ts @@ -1,7 +1,7 @@ import { db } from "@superset/db/client"; import { chatSessions } from "@superset/db/schema"; import { and, eq, isNull } from "drizzle-orm"; -import { getDurableStream, requireAuth } from "../lib"; +import { findChatSessionOwner, getDurableStream, requireAuth } from "../lib"; function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -44,6 +44,11 @@ export async function PUT( ); } + const existingOwner = await findChatSessionOwner(sessionId); + if (existingOwner && existingOwner.createdBy !== session.user.id) { + return new Response("Not found", { status: 404 }); + } + const stream = getDurableStream(sessionId); try { await stream.create({ contentType: "application/json" }); @@ -139,10 +144,20 @@ export async function PATCH( const body = (await request.json()) as { title?: string }; if (body.title !== undefined) { - await db + const [updated] = await db .update(chatSessions) .set({ title: body.title }) - .where(eq(chatSessions.id, sessionId)); + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.createdBy, session.user.id), + ), + ) + .returning({ id: chatSessions.id }); + + if (!updated) { + return new Response("Not found", { status: 404 }); + } } return Response.json({ success: true }, { status: 200 }); diff --git a/apps/api/src/app/api/chat/[sessionId]/stream/route.ts b/apps/api/src/app/api/chat/[sessionId]/stream/route.ts index 37e855739..3c16ff7e8 100644 --- a/apps/api/src/app/api/chat/[sessionId]/stream/route.ts +++ b/apps/api/src/app/api/chat/[sessionId]/stream/route.ts @@ -1,8 +1,9 @@ import { db } from "@superset/db/client"; import { chatSessions } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { + loadOwnedChatSession, PRODUCER_RESPONSE_HEADERS, PROTOCOL_QUERY_PARAMS, PROTOCOL_RESPONSE_HEADERS, @@ -23,6 +24,10 @@ export async function GET( if (!session) return new Response("Unauthorized", { status: 401 }); const { sessionId } = await params; + + const owned = await loadOwnedChatSession(sessionId, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + const url = new URL(request.url); const upstream = new URL(streamUrl(sessionId)); @@ -83,6 +88,10 @@ export async function POST( if (!session) return new Response("Unauthorized", { status: 401 }); const { sessionId } = await params; + + const owned = await loadOwnedChatSession(sessionId, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + const upstream = streamUrl(sessionId); const headers: Record = { @@ -137,6 +146,9 @@ export async function DELETE( const { sessionId } = await params; + const owned = await loadOwnedChatSession(sessionId, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + const response = await fetch(streamUrl(sessionId), { method: "DELETE", headers: { @@ -144,7 +156,14 @@ export async function DELETE( }, }); - await db.delete(chatSessions).where(eq(chatSessions.id, sessionId)); + await db + .delete(chatSessions) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.createdBy, session.user.id), + ), + ); const headers = new Headers(); for (const [key, value] of response.headers.entries()) { @@ -172,6 +191,9 @@ export async function HEAD( const { sessionId } = await params; + const owned = await loadOwnedChatSession(sessionId, session.user.id); + if (!owned) return new Response("Not found", { status: 404 }); + const response = await fetch(streamUrl(sessionId), { method: "HEAD", headers: { diff --git a/apps/api/src/app/api/chat/lib.ts b/apps/api/src/app/api/chat/lib.ts index 036011ace..55eccfe3a 100644 --- a/apps/api/src/app/api/chat/lib.ts +++ b/apps/api/src/app/api/chat/lib.ts @@ -1,5 +1,8 @@ import { DurableStream } from "@durable-streams/client"; import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { chatSessions } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; export const PROTOCOL_QUERY_PARAMS = ["offset", "live", "cursor"]; @@ -36,6 +39,26 @@ export async function requireAuth(request: Request) { return sessionData; } +export async function loadOwnedChatSession(sessionId: string, userId: string) { + const [row] = await db + .select({ id: chatSessions.id, createdBy: chatSessions.createdBy }) + .from(chatSessions) + .where( + and(eq(chatSessions.id, sessionId), eq(chatSessions.createdBy, userId)), + ) + .limit(1); + return row ?? null; +} + +export async function findChatSessionOwner(sessionId: string) { + const [row] = await db + .select({ createdBy: chatSessions.createdBy }) + .from(chatSessions) + .where(eq(chatSessions.id, sessionId)) + .limit(1); + return row ?? null; +} + export function streamUrl(sessionId: string) { return `${env.DURABLE_STREAMS_URL}/sessions/${sessionId}`; } From 71062f5a3854fdef51f23241735e6b8cc0fbeb49 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 31 May 2026 21:00:37 -0700 Subject: [PATCH 2/3] fix(api): reject OAuth tokens from untrusted clients on tRPC (#5018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tRPC bearer path granted a full user session to any JWT whose audience matched the API URL, without checking which OAuth client minted it (azp). Combined with unauthenticated dynamic client registration, an attacker could register a client with an attacker-controlled redirect_uri, phish a victim through the consent screen, and exchange the code for a token with aud=api.superset.sh + organizationIds — yielding full read/write tRPC access (profile, billing, etc.). Gate the bearer path to trusted first-party clients (superset-cli). Tokens minted by dynamically-registered clients (azp = random registered id) are now rejected. JWT-plugin tokens (no azp, only obtainable with an existing session or API key) and API keys are unaffected, as are the MCP path (separate verifier) and the web app (cookies). --- apps/api/src/trpc/context.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts index 57d9915bb..27c4fdbf1 100644 --- a/apps/api/src/trpc/context.ts +++ b/apps/api/src/trpc/context.ts @@ -8,6 +8,8 @@ import { env } from "@/env"; const apiUrl = env.NEXT_PUBLIC_API_URL.replace(/\/+$/, ""); +const TRUSTED_API_CLIENTS = new Set(["superset-cli"]); + function looksLikeJwt(token: string): boolean { const parts = token.split("."); return parts.length === 3 && parts.every(Boolean); @@ -34,6 +36,12 @@ async function sessionFromOAuthBearer( return null; } + const authorizedClientId = + typeof payload.azp === "string" ? payload.azp : null; + if (authorizedClientId && !TRUSTED_API_CLIENTS.has(authorizedClientId)) { + return null; + } + const userId = typeof payload.sub === "string" ? payload.sub : null; if (!userId) return null; From 9bf4052b13061710e64bf96f15c384007f1d1899 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 31 May 2026 21:58:07 -0700 Subject: [PATCH 3/3] fix(relay): read directory from regional replicas (disable readYourWrites) (#5019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @upstash/redis defaults readYourWrites to true, which stamps an upstash-sync-token on every request and forces each read to block until the nearest replica has caught up to this client's latest write. Because every relay writes continuously (register/heartbeat/sweep), that token keeps advancing, so directory.lookup never gets a fast local replica read — it pays cross-region replication lag on every cross-region routing decision, defeating the per-region read replicas. The directory is eventually-consistent by design (90s TTL + sweepStale + the maybeReplay self-owner guard), so read-your-writes is not needed. Disable it so lookups serve from the nearest regional replica. --- apps/relay/src/directory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/relay/src/directory.ts b/apps/relay/src/directory.ts index 25081cbcd..d38fdb175 100644 --- a/apps/relay/src/directory.ts +++ b/apps/relay/src/directory.ts @@ -11,6 +11,7 @@ const TTL_GRACE_MS = 90_000; const redis = new Redis({ url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, + readYourWrites: false, }); export interface TunnelOwner {