diff --git a/.changeset/harden-webhook-tenancy.md b/.changeset/harden-webhook-tenancy.md new file mode 100644 index 000000000..c063774ab --- /dev/null +++ b/.changeset/harden-webhook-tenancy.md @@ -0,0 +1,8 @@ +--- +"@chat-adapter/gchat": minor +"@chat-adapter/slack": patch +"@chat-adapter/teams": patch +"chat": patch +--- + +Harden webhook tenant isolation, require explicit Google Chat bot identity for reliable mention handling, use native Google Chat pagination, isolate Slack caches, and bound recording storage. diff --git a/apps/docs/content/adapters/official/gchat.mdx b/apps/docs/content/adapters/official/gchat.mdx index 6cdfe90a1..edcf3cd60 100644 --- a/apps/docs/content/adapters/official/gchat.mdx +++ b/apps/docs/content/adapters/official/gchat.mdx @@ -95,6 +95,11 @@ bot.onNewMention(async (thread, message) => { description: "Service account credentials JSON. Auto-detected from `GOOGLE_CHAT_CREDENTIALS`.", }, + botUserId: { + type: "string", + description: + "Canonical users/... resource name of this Chat app. Required for exact self-message and mention detection; auto-detected from `GOOGLE_CHAT_BOT_USER_ID`.", + }, useApplicationDefaultCredentials: { type: "boolean", description: @@ -144,6 +149,10 @@ bot.onNewMention(async (thread, message) => { One of `googleChatProjectNumber`, `endpointUrl`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive. +Set `botUserId` (or `GOOGLE_CHAT_BOT_USER_ID`) to the canonical `sender.name` from a verified message authored by your Chat app, such as `users/123456789`. The adapter never learns this identity from inbound mentions. When it is omitted, all `BOT` senders are conservatively treated as self to prevent reply loops. + +Apps upgrading from an earlier release that rely on mention handlers must configure `botUserId` to preserve mention handling. Without it, bot mention annotations are left unchanged and the default `onNewMention` detection may no longer match them. + ## Authentication ### 1. Create a GCP project diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example index 19e0d3eac..e53f13da0 100644 --- a/examples/nextjs-chat/.env.example +++ b/examples/nextjs-chat/.env.example @@ -13,6 +13,8 @@ BOT_USERNAME=mybot # Google Chat (optional) # GOOGLE_CHAT_CREDENTIALS={"type":"service_account",...} +# Canonical sender.name from a verified message authored by your Chat app. +# GOOGLE_CHAT_BOT_USER_ID=users/123456789 # Discord (optional) # DISCORD_BOT_TOKEN=your-bot-token @@ -73,3 +75,9 @@ BOT_USERNAME=mybot # Redis (for production state persistence) # REDIS_URL=redis://localhost:6379 + +# Preview branch webhook proxy (optional) +# Required to view or update /settings. Use a long, randomly generated value. +# PREVIEW_BRANCH_SECRET=replace-with-a-random-secret +# Optional comma-separated exact hostnames. Defaults to any *.vercel.app host. +# PREVIEW_BRANCH_ALLOWED_HOSTS=my-feature-git-main-team.vercel.app diff --git a/examples/nextjs-chat/README.md b/examples/nextjs-chat/README.md index 684771ce6..a8a09de6b 100644 --- a/examples/nextjs-chat/README.md +++ b/examples/nextjs-chat/README.md @@ -132,12 +132,16 @@ pnpm recording:export See `packages/integration-tests/fixtures/replay/README.md` for the full workflow. +Only successful, adapter-verified webhook deliveries are recorded. Individual records are limited to 256 KiB and each session retains at most 500 entries. + ## Preview branch testing Test PRs with real webhook traffic by proxying requests from production to a preview deployment: 1. Deploy a preview branch to Vercel -2. Go to `/settings` on the production deployment -3. Enter the preview branch URL and save +2. Set `PREVIEW_BRANCH_SECRET` on the production deployment to a long, random value +3. Optionally set `PREVIEW_BRANCH_ALLOWED_HOSTS` to a comma-separated list of exact preview hostnames (otherwise any `*.vercel.app` hostname is accepted) +4. Go to `/settings` on the production deployment +5. Enter the operator secret, load the setting, then enter the preview branch URL and save -All webhook requests are proxied until the URL is cleared. +The settings API requires the operator secret as a bearer token. Only HTTPS Vercel deployments or explicitly allowed hosts are accepted, and all webhook requests are proxied until the URL is cleared. diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index b1741e20c..a69135945 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test": "vitest run", "typecheck": "tsc --noEmit", "recording:list": "tsx src/lib/recorder.ts --list", "recording:export": "tsx src/lib/recorder.ts" @@ -45,6 +46,7 @@ "dotenv": "^17.2.3", "postcss": "^8.5.26", "tsx": "^4.21.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.0.18" } } diff --git a/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts b/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts index 46c705e54..8944ca659 100644 --- a/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts +++ b/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts @@ -1,7 +1,11 @@ import { createClient } from "redis"; +import { authorizePreviewBranchRequest } from "@/lib/authorization"; +import { + PREVIEW_BRANCH_KEY, + parseAllowedPreviewBranchUrl, +} from "@/lib/preview-branch"; const REDIS_URL = process.env.REDIS_URL || ""; -const PREVIEW_BRANCH_KEY = "chat-sdk:cache:preview-branch-url"; // Redis client singleton let redisClient: ReturnType | null = null; @@ -29,7 +33,12 @@ async function getRedisClient() { return redisClient; } -export async function GET(): Promise { +export async function GET(request: Request): Promise { + const unauthorized = authorizePreviewBranchRequest(request); + if (unauthorized) { + return unauthorized; + } + try { const client = await getRedisClient(); const value = await client.get(PREVIEW_BRANCH_KEY); @@ -45,26 +54,46 @@ export async function GET(): Promise { } export async function POST(request: Request): Promise { + const unauthorized = authorizePreviewBranchRequest(request); + if (unauthorized) { + return unauthorized; + } + try { - const body = await request.json(); - const { url } = body; + const body: unknown = await request.json(); + if (!(body && typeof body === "object" && "url" in body)) { + return Response.json({ error: "Missing URL" }, { status: 400 }); + } + const { url } = body as { url: unknown }; const client = await getRedisClient(); - if (url) { - // Validate URL - try { - new URL(url); - } catch { - return Response.json({ error: "Invalid URL" }, { status: 400 }); + if (typeof url === "string" && url.length > 0) { + const allowedUrl = parseAllowedPreviewBranchUrl(url); + if (!allowedUrl) { + return Response.json( + { + error: + "URL must be an HTTPS Vercel deployment or a configured allowed host", + }, + { status: 400 } + ); } - await client.set(PREVIEW_BRANCH_KEY, url); - } else { + await client.set(PREVIEW_BRANCH_KEY, allowedUrl.origin); + return Response.json({ success: true, url: allowedUrl.origin }); + } + if (url === null || url === "") { // Clear the preview branch URL await client.del(PREVIEW_BRANCH_KEY); + return Response.json({ success: true, url: null }); } - return Response.json({ success: true, url: url || null }); + return Response.json( + { error: "URL must be a string or null" }, + { + status: 400, + } + ); } catch (error) { console.error("[settings] Error setting preview branch URL:", error); return Response.json( diff --git a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts index 3ae0ce1bb..36f6bc5fd 100644 --- a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts +++ b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts @@ -16,16 +16,18 @@ export async function POST( return new Response(`Unknown platform: ${platform}`, { status: 404 }); } - // Record webhook if enabled (no-op if disabled) - if (recorder.isEnabled) { - await recorder.recordWebhook(platform, request.clone()); - } + const recordingRequest = recorder.isEnabled ? request.clone() : null; // Handle the webhook with waitUntil for background processing // Next.js after() ensures work completes after the response is sent - return webhookHandler(request, { + const response = await webhookHandler(request, { waitUntil: (task) => after(() => task), }); + // Only successful, adapter-verified deliveries are eligible for recording. + if (recordingRequest && response.ok) { + await recorder.recordWebhook(platform, recordingRequest); + } + return response; } // GET handler — serves as health check, but also forwards to webhook handler diff --git a/examples/nextjs-chat/src/app/settings/page.tsx b/examples/nextjs-chat/src/app/settings/page.tsx index fca87d8d4..25cd52c6d 100644 --- a/examples/nextjs-chat/src/app/settings/page.tsx +++ b/examples/nextjs-chat/src/app/settings/page.tsx @@ -1,34 +1,47 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; export default function SettingsPage() { + const [operatorSecret, setOperatorSecret] = useState(""); + const [authenticated, setAuthenticated] = useState(false); const [previewBranchUrl, setPreviewBranchUrl] = useState(""); const [savedUrl, setSavedUrl] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "saving" | "error">( - "loading" + "idle" ); const [error, setError] = useState(null); - // Load current setting on mount - useEffect(() => { - fetch("/api/settings/preview-branch") - .then((res) => res.json()) - .then((data) => { - if (data.error) { - setError(data.error); - setStatus("error"); - } else { - setPreviewBranchUrl(data.url || ""); - setSavedUrl(data.url); - setStatus("idle"); - } - }) - .catch((err) => { - setError(err.message); - setStatus("error"); + const authorizationHeaders = { + Authorization: `Bearer ${operatorSecret}`, + }; + + const handleLoad = async () => { + setStatus("loading"); + setError(null); + + try { + const res = await fetch("/api/settings/preview-branch", { + headers: authorizationHeaders, }); - }, []); + const data = await res.json(); + if (!res.ok || data.error) { + setAuthenticated(false); + setError(data.error || "Unable to load settings"); + setStatus("error"); + return; + } + + setAuthenticated(true); + setPreviewBranchUrl(data.url || ""); + setSavedUrl(data.url); + setStatus("idle"); + } catch (err) { + setAuthenticated(false); + setError(err instanceof Error ? err.message : "Unknown error"); + setStatus("error"); + } + }; const handleSave = async () => { setStatus("saving"); @@ -37,13 +50,16 @@ export default function SettingsPage() { try { const res = await fetch("/api/settings/preview-branch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + ...authorizationHeaders, + "Content-Type": "application/json", + }, body: JSON.stringify({ url: previewBranchUrl || null }), }); const data = await res.json(); - if (data.error) { + if (!res.ok || data.error) { setError(data.error); setStatus("error"); } else { @@ -64,13 +80,16 @@ export default function SettingsPage() { try { const res = await fetch("/api/settings/preview-branch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + ...authorizationHeaders, + "Content-Type": "application/json", + }, body: JSON.stringify({ url: null }), }); const data = await res.json(); - if (data.error) { + if (!res.ok || data.error) { setError(data.error); setStatus("error"); } else { @@ -101,9 +120,63 @@ export default function SettingsPage() { webhook traffic.

- {status === "loading" ? ( -

Loading...

- ) : ( +
+ +
+ { + setOperatorSecret(event.target.value); + setAuthenticated(false); + }} + placeholder="PREVIEW_BRANCH_SECRET" + style={{ + width: "100%", + padding: "0.5rem", + fontSize: "1rem", + border: "1px solid #ccc", + borderRadius: "4px", + boxSizing: "border-box", + }} + type="password" + value={operatorSecret} + /> + +
+

+ This secret stays in this browser tab and is sent only in the + authorization header. +

+
+ + {error && ( +

+ {error} +

+ )} + + {authenticated ? ( <>
- {error && ( -

{error}

- )} -