From c997b74d011c8a72351037a61199c95a933e1111 Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Fri, 30 Oct 2026 21:48:49 +0100 Subject: [PATCH] Verify Sanity webhook signatures in the revalidate route The secret was passed in the query string, which ends up in access logs and doesn't protect the request body. Verify the signed header with @sanity/webhook instead, and return 400 for invalid JSON. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ package-lock.json | 10 ++++++++++ package.json | 1 + src/app/api/revalidate/route.ts | 32 ++++++++++++++++++++++++++++---- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89597f0..a8eff89 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ MEN Du må spesifisere URL for webhook i Sanity sitt dashbord. URLen dette webhooket skal peke på er `https://uiogaming.no/api/revalidate` med _POST_ forespørselstype. +Sett feltet _Secret_ på webhooket til samme verdi som `SANITY_REVALIDATE_SECRET` i `.env`. Sanity signerer da hver forespørsel, og APIet avviser forespørsler uten gyldig signatur. + Du kan lese mer om det [her](https://www.sanity.io/docs/webhooks). ## Vil du bidra? diff --git a/package-lock.json b/package-lock.json index 7f47a7c..cfac592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@portabletext/react": "^3.2.1", "@sanity/client": "^7.8.1", "@sanity/image-url": "^1.1.0", + "@sanity/webhook": "^4.0.4", "country-flag-icons": "^1.5.19", "install": "^0.13.0", "next": "^16.3.3", @@ -1433,6 +1434,15 @@ "node": ">=10.0.0" } }, + "node_modules/@sanity/webhook": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sanity/webhook/-/webhook-4.0.4.tgz", + "integrity": "sha512-c8LmzR5Wx93zJ10ohhp0XlmPrTNSFyvPcLbNY/sN45Wj5VOngz4FbBMbX9j64sSQLt+676U6OhiVfjs1yw3YCw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@schummar/icu-type-parser": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", diff --git a/package.json b/package.json index c0da613..b264d1f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@portabletext/react": "^3.2.1", "@sanity/client": "^7.8.1", "@sanity/image-url": "^1.1.0", + "@sanity/webhook": "^4.0.4", "country-flag-icons": "^1.5.19", "install": "^0.13.0", "next": "^16.3.3", diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index d9404c0..10a825d 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,3 +1,4 @@ +import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook" import { revalidateTag } from "next/cache" import { NextRequest, NextResponse } from "next/server" @@ -6,14 +7,37 @@ import { NextRequest, NextResponse } from "next/server" const expireTag = (tag: string) => revalidateTag(tag, { expire: 0 }) export async function POST(request: NextRequest) { - const secret = request.nextUrl.searchParams.get("secret") + const secret = process.env.SANITY_REVALIDATE_SECRET + if (!secret) { + console.error("SANITY_REVALIDATE_SECRET is not set") + return NextResponse.json( + { message: "Webhook is not configured" }, + { status: 500 } + ) + } - if (secret !== process.env.SANITY_REVALIDATE_SECRET) { - return NextResponse.json({ message: "Invalid secret" }, { status: 401 }) + // Sanity signs the raw body with the webhook secret, so verify it before + // parsing + const rawBody = await request.text() + const signature = request.headers.get(SIGNATURE_HEADER_NAME) ?? "" + + // Throws on a malformed signature header instead of returning false + const isValid = await isValidSignature(rawBody, signature, secret).catch( + () => false + ) + + if (!isValid) { + return NextResponse.json({ message: "Invalid signature" }, { status: 401 }) + } + + let body + try { + body = JSON.parse(rawBody) + } catch { + return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 }) } try { - const body = await request.json() const { _type, slug } = body console.log(