From 418ebe097ec0648d0aa37448dfc80b05633938db Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 12 Aug 2026 13:56:59 -0700 Subject: [PATCH 1/2] feat(ci): auto-comment shareable preview links on PRs Contractors need to preview PR deployments, but Vercel Deployment Protection now gates all preview URLs (to stop bots/crawlers hitting old builds and creating Sentry noise). Manually minting a shareable link per deployment is a bottleneck. This adds an automated, hardened flow: - app/api/preview-share/route.ts: a public redirect endpoint served from docs.sentry.io. It verifies an HMAC signature + 30-day expiry + host allowlist, then 302-redirects to the preview origin with Vercel's bypass params (x-vercel-protection-bypass + x-vercel-set-bypass-cookie), which sets a bypass cookie so the whole preview is browsable. The Vercel bypass secret lives only in server env vars, never in the PR comment. - .github/workflows/preview-share-link.yml: on deployment_status success for a preview (production skipped), resolves the PR from the commit SHA, signs a 30-day link, and upserts a single sticky PR comment. Runs in the base-repo context so it works for fork PRs. - Covers both docs projects (sentry-docs + develop-docs) via host-based routing to the correct project's bypass secret. Bare preview URLs stay protected; only signed, expiring links grant access. Setup/rotation steps and required secrets are documented in app/api/preview-share/README.md. --- .github/workflows/preview-share-link.yml | 147 +++++++++++++++++++++++ app/api/preview-share/README.md | 77 ++++++++++++ app/api/preview-share/route.test.ts | 126 +++++++++++++++++++ app/api/preview-share/route.ts | 140 +++++++++++++++++++++ 4 files changed, 490 insertions(+) create mode 100644 .github/workflows/preview-share-link.yml create mode 100644 app/api/preview-share/README.md create mode 100644 app/api/preview-share/route.test.ts create mode 100644 app/api/preview-share/route.ts diff --git a/.github/workflows/preview-share-link.yml b/.github/workflows/preview-share-link.yml new file mode 100644 index 0000000000000..a12e725e8c51c --- /dev/null +++ b/.github/workflows/preview-share-link.yml @@ -0,0 +1,147 @@ +name: Preview Share Link + +# When Vercel finishes a *preview* deployment, post (or update) a single sticky +# comment on the associated PR with a shareable link that lets contractors open +# the protection-gated preview in their browser. +# +# The Vercel "Protection Bypass for Automation" secret is NEVER placed in the +# comment. Instead we post a link to our own public redirect endpoint +# (`/api/preview-share` on docs.sentry.io), signing it with an HMAC so the +# public endpoint cannot be driven by bots that merely discover a preview URL. +# +# Runs on `deployment_status`, which executes in the base-repo context and has +# access to secrets even for pull requests opened from forks. + +on: + deployment_status: + +# Avoid duplicate work when Vercel emits multiple status events for the same +# deployment; the comment upsert is idempotent regardless. +concurrency: + group: preview-share-${{ github.event.deployment.sha }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + +jobs: + comment: + # Only act on successful, non-production deployments. + if: >- + github.event.deployment_status.state == 'success' && + !contains(github.event.deployment_status.environment, 'Production') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SHARE_LINK_SIGNING_KEY: ${{ secrets.SHARE_LINK_SIGNING_KEY }} + SHARE_BASE_URL: ${{ vars.SHARE_BASE_URL }} + LINK_TTL_DAYS: '30' + with: + script: | + const crypto = require('node:crypto'); + + const {owner, repo} = context.repo; + const ds = context.payload.deployment_status; + const deployment = context.payload.deployment; + + const signingKey = process.env.SHARE_LINK_SIGNING_KEY; + const baseUrl = process.env.SHARE_BASE_URL; + if (!signingKey || !baseUrl) { + core.setFailed('Missing SHARE_LINK_SIGNING_KEY secret or SHARE_BASE_URL variable.'); + return; + } + + // Resolve the preview URL and reduce it to its origin. + const targetUrl = ds.target_url || ds.environment_url; + if (!targetUrl) { + core.info('No target_url on deployment_status; nothing to do.'); + return; + } + let origin, host; + try { + const parsed = new URL(targetUrl); + origin = parsed.origin; + host = parsed.host; + } catch { + core.info(`Unparseable target_url: ${targetUrl}`); + return; + } + + // Only handle our docs preview hosts (user docs + developer docs). + const ALLOWED = /^(sentry-docs|develop-docs)[a-z0-9-]*\.(sentry\.dev|vercel\.app)$/; + if (!ALLOWED.test(host)) { + core.info(`Host is not a docs preview host, skipping: ${host}`); + return; + } + const isDevelop = host.startsWith('develop-docs'); + const projectLabel = isDevelop ? 'Developer docs' : 'User docs'; + + // Map the deployed commit back to its PR. + const sha = deployment.sha; + const {data: prs} = + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: sha, + }); + const pr = prs.find(p => p.state === 'open') || prs[0]; + if (!pr) { + core.info(`No PR associated with ${sha}; nothing to comment on.`); + return; + } + + // Build the signed, expiring share link. + const ttlDays = Number(process.env.LINK_TTL_DAYS || '30'); + const exp = Math.floor(Date.now() / 1000) + ttlDays * 24 * 60 * 60; + const sig = crypto + .createHmac('sha256', signingKey) + .update(`${origin}|${exp}`) + .digest('hex'); + const shareUrl = + `${baseUrl.replace(/\/$/, '')}/api/preview-share` + + `?u=${encodeURIComponent(origin)}&exp=${exp}&sig=${sig}`; + const expDate = new Date(exp * 1000).toISOString().slice(0, 10); + + const marker = ''; + const body = [ + marker, + `### 🔓 Shareable preview link`, + ``, + `This preview is behind Vercel Deployment Protection. Open it in your ` + + `browser with the link below — no Vercel login required:`, + ``, + `**[Open ${projectLabel} preview →](${shareUrl})**`, + ``, + `- Bare preview URL (login required): <${origin}>`, + `- Link expires: **${expDate}** — push a new commit for a fresh link.`, + ``, + `Generated automatically. The link sets a one-time bypass cookie so ` + + `you can browse the whole preview normally.`, + ].join('\n'); + + // Upsert a single sticky comment (paginate to avoid duplicates on + // long PR threads). + const comments = await github.paginate( + github.rest.issues.listComments, + {owner, repo, issue_number: pr.number, per_page: 100} + ); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + core.info(`Updated share-link comment on PR #${pr.number}.`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + core.info(`Created share-link comment on PR #${pr.number}.`); + } diff --git a/app/api/preview-share/README.md b/app/api/preview-share/README.md new file mode 100644 index 0000000000000..69fc55c173f1a --- /dev/null +++ b/app/api/preview-share/README.md @@ -0,0 +1,77 @@ +# Preview share links + +Auto-generates a **shareable link** for protection-gated Vercel preview +deployments and posts it as a sticky comment on the PR, so external +contractors can view previews without a Vercel login — while the bare preview +URLs stay locked down against bots and crawlers. + +## How it works + +1. **`.github/workflows/preview-share-link.yml`** runs on `deployment_status`. + When Vercel reports a successful **preview** deployment (production is + skipped), it: + - resolves the PR from the deployed commit SHA, + - builds an HMAC-signed, 30-day-expiring token for the preview origin, + - upserts one sticky PR comment linking to this endpoint. +2. **`app/api/preview-share/route.ts`** (this endpoint, served publicly from + `docs.sentry.io`) verifies the signature + expiry + host allowlist, then + `302`-redirects to the preview origin with Vercel's bypass params + (`x-vercel-protection-bypass` + `x-vercel-set-bypass-cookie=true`). Vercel + sets a bypass cookie and the contractor can browse the whole preview. + +The Vercel bypass secret lives **only** in this endpoint's server-side env +vars. It is never written into the (public) PR comment. Links are signed, so a +bot that merely discovers a preview URL cannot forge a working share link. + +## One-time setup + +### 1. Create the Vercel bypass secrets (both docs projects) + +For **user-docs** and **develop-docs**: +Vercel → Project → Settings → Deployment Protection → _Protection Bypass for +Automation_ → **Create** (label e.g. "preview share links"). Copy each value. + +Keep **Standard Protection** enabled (previews protected, production public). + +### 2. Env vars — only on the `sentry-docs` (user-docs) project + +The endpoint runs only where the share link points (`docs.sentry.io`), which is +the `sentry-docs` project. Set these there, for the **Production** environment. +One endpoint holds both projects' bypass secrets: + +| Name | Value | +| ---------------------------- | ------------------------------------------------------ | +| `SHARE_LINK_SIGNING_KEY` | a fresh random 32-byte secret (`openssl rand -hex 32`) | +| `BYPASS_SECRET_USER_DOCS` | bypass secret from the `sentry-docs` project | +| `BYPASS_SECRET_DEVELOP_DOCS` | bypass secret from the `develop-docs` project | + +The **`develop-docs`** project needs **no** endpoint env vars — you only create +its Protection Bypass secret (step 1) to copy the value above. The route code +also ships in the develop-docs deployment, but with no env vars it fails closed +there (harmless, unused). + +### 3. GitHub repo config (getsentry/sentry-docs) + +| Kind | Name | Value | +| -------------------- | ------------------------ | ------------------------ | +| Actions **secret** | `SHARE_LINK_SIGNING_KEY` | same value as above | +| Actions **variable** | `SHARE_BASE_URL` | `https://docs.sentry.io` | + +### 4. Ship it + +Merge to `master` so the endpoint goes live on production. From then on every +new PR gets an automatic share-link comment. (The PR that introduces this +feature won't have a working link until it merges — one-time only.) + +## Rotating the secret + +Regenerate the bypass secret in Vercel, update `BYPASS_SECRET_*`, and redeploy. +To rotate signing, replace `SHARE_LINK_SIGNING_KEY` in both the Vercel project +and the GitHub Actions secret (existing links stop working immediately). + +## Notes + +- Allowed preview hosts: `sentry-docs*` / `develop-docs*` on `.sentry.dev` or + `.vercel.app`. Anything else is rejected by the endpoint. +- The endpoint is `noindex` and `no-store`. +- Tests: `pnpm test app/api/preview-share`. diff --git a/app/api/preview-share/route.test.ts b/app/api/preview-share/route.test.ts new file mode 100644 index 0000000000000..970a4eca834a5 --- /dev/null +++ b/app/api/preview-share/route.test.ts @@ -0,0 +1,126 @@ +import {createHmac} from 'node:crypto'; + +import {NextRequest} from 'next/server'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {GET} from './route'; + +const SIGNING_KEY = 'test-signing-key'; +const USER_SECRET = 'user-bypass-secret'; +const DEVELOP_SECRET = 'develop-bypass-secret'; + +const ENDPOINT = 'https://docs.sentry.io/api/preview-share'; + +function sign(url: string, exp: number): string { + return createHmac('sha256', SIGNING_KEY).update(`${url}|${exp}`).digest('hex'); +} + +function buildRequest({ + u, + exp, + sig, +}: { + exp?: number | string; + sig?: string; + u?: string; +}): NextRequest { + const params = new URLSearchParams(); + if (u !== undefined) params.set('u', u); + if (exp !== undefined) params.set('exp', String(exp)); + if (sig !== undefined) params.set('sig', sig); + return new NextRequest(`${ENDPOINT}?${params.toString()}`); +} + +const FUTURE = () => Math.floor(Date.now() / 1000) + 60 * 60; +const PAST = () => Math.floor(Date.now() / 1000) - 60; + +describe('preview-share route', () => { + beforeEach(() => { + vi.stubEnv('SHARE_LINK_SIGNING_KEY', SIGNING_KEY); + vi.stubEnv('BYPASS_SECRET_USER_DOCS', USER_SECRET); + vi.stubEnv('BYPASS_SECRET_DEVELOP_DOCS', DEVELOP_SECRET); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('redirects a valid user-docs link with the user bypass secret', () => { + const u = 'https://sentry-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(location.host).toBe('sentry-docs-git-my-branch.sentry.dev'); + expect(location.searchParams.get('x-vercel-protection-bypass')).toBe(USER_SECRET); + expect(location.searchParams.get('x-vercel-set-bypass-cookie')).toBe('true'); + expect(res.headers.get('cache-control')).toBe('no-store'); + }); + + it('redirects a valid develop-docs link with the develop bypass secret', () => { + const u = 'https://develop-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(location.searchParams.get('x-vercel-protection-bypass')).toBe(DEVELOP_SECRET); + }); + + it('accepts vercel.app generated preview hosts', () => { + const u = 'https://sentry-docs-abc123-getsentry.vercel.app'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(302); + }); + + it('returns 400 when params are missing', () => { + const res = GET(buildRequest({u: 'https://sentry-docs-git-x.sentry.dev'})); + expect(res.status).toBe(400); + }); + + it('returns 403 for a bad signature', () => { + const u = 'https://sentry-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: 'deadbeef'})); + expect(res.status).toBe(403); + }); + + it('returns 403 if the url is swapped after signing', () => { + const signed = 'https://sentry-docs-git-my-branch.sentry.dev'; + const evil = 'https://develop-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u: evil, exp, sig: sign(signed, exp)})); + expect(res.status).toBe(403); + }); + + it('returns 410 for an expired link', () => { + const u = 'https://sentry-docs-git-my-branch.sentry.dev'; + const exp = PAST(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(410); + }); + + it('returns 400 for a disallowed host', () => { + const u = 'https://evil.example.com'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(400); + }); + + it('returns 400 for a non-https target', () => { + const u = 'http://sentry-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(400); + }); + + it('fails closed when the signing key is not configured', () => { + vi.stubEnv('SHARE_LINK_SIGNING_KEY', ''); + const u = 'https://sentry-docs-git-my-branch.sentry.dev'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/preview-share/route.ts b/app/api/preview-share/route.ts new file mode 100644 index 0000000000000..d2f2b93f2e523 --- /dev/null +++ b/app/api/preview-share/route.ts @@ -0,0 +1,140 @@ +import {createHmac, timingSafeEqual} from 'node:crypto'; + +import {NextRequest, NextResponse} from 'next/server'; + +// This endpoint must run on the Node.js runtime (uses node:crypto) and must +// never be statically optimized, since it depends on per-request query params +// and server-only secrets. +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Public redirect endpoint that grants contractors browser access to a + * protected Vercel preview deployment WITHOUT exposing the Vercel + * "Protection Bypass for Automation" secret in a public PR comment. + * + * Flow: + * 1. The `preview-share-link` GitHub workflow signs a short-lived token for a + * specific preview origin and posts a link to this endpoint in the PR. + * 2. A contractor clicks the link. This endpoint verifies the HMAC signature, + * the expiry, and that the target host is one of our docs preview domains. + * 3. It then 302-redirects the browser to the preview origin with Vercel's + * bypass query params, which sets the bypass cookie and lets them browse. + * + * The bypass secret lives only in this server's env vars — it is never placed + * in the (public) PR comment. + */ + +// Only these hosts may ever be used as a redirect target. Both docs projects +// (user docs = `sentry-docs`, developer docs = `develop-docs`) deploy previews +// to `*.sentry.dev` and Vercel-generated `*.vercel.app` URLs. +const ALLOWED_PREVIEW_HOST = + /^(sentry-docs|develop-docs)[a-z0-9-]*\.(sentry\.dev|vercel\.app)$/; + +function timingSafeStrEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) { + return false; + } + return timingSafeEqual(ab, bb); +} + +function errorPage(message: string, status: number): NextResponse { + const html = ` + + + + + + Preview link unavailable + + + +
+

Preview link unavailable

+

${message}

+

Push a new commit to the pull request (or re-run the deployment) to get a fresh preview link.

+
+ +`; + return new NextResponse(html, { + status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex, nofollow', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} + +export function GET(request: NextRequest): NextResponse { + const signingKey = process.env.SHARE_LINK_SIGNING_KEY; + if (!signingKey) { + // Misconfiguration — fail closed. + return errorPage('This preview link service is not configured.', 500); + } + + const params = request.nextUrl.searchParams; + const url = params.get('u'); + const exp = params.get('exp'); + const sig = params.get('sig'); + + if (!url || !exp || !sig) { + return errorPage('This preview link is missing required parameters.', 400); + } + + // Verify signature first (covers both `u` and `exp`), using a constant-time + // comparison so we don't leak information about the expected signature. + const expected = createHmac('sha256', signingKey).update(`${url}|${exp}`).digest('hex'); + if (!timingSafeStrEqual(sig, expected)) { + return errorPage('This preview link is invalid or has been tampered with.', 403); + } + + // Verify expiry (seconds since epoch). + const expSeconds = Number(exp); + if (!Number.isFinite(expSeconds) || Date.now() > expSeconds * 1000) { + return errorPage('This preview link has expired.', 410); + } + + // Parse and validate the target host against the allowlist. + let target: URL; + try { + target = new URL(url); + } catch { + return errorPage('This preview link points to an invalid URL.', 400); + } + if (target.protocol !== 'https:' || !ALLOWED_PREVIEW_HOST.test(target.host)) { + return errorPage('This preview link points to a host that is not allowed.', 400); + } + + // Pick the correct project's bypass secret based on the host. The host is + // covered by the signature, so this decision is integrity-protected. + const isDevelopDocs = target.host.startsWith('develop-docs'); + const bypassSecret = isDevelopDocs + ? process.env.BYPASS_SECRET_DEVELOP_DOCS + : process.env.BYPASS_SECRET_USER_DOCS; + + if (!bypassSecret) { + return errorPage('This preview link service is missing a bypass secret.', 500); + } + + // Redirect to the preview origin root with Vercel's bypass params. The + // `x-vercel-set-bypass-cookie=true` flag makes Vercel set an auth-bypass + // cookie so the contractor can browse the whole deployment normally. + const redirectTarget = new URL('/', target.origin); + redirectTarget.searchParams.set('x-vercel-protection-bypass', bypassSecret); + redirectTarget.searchParams.set('x-vercel-set-bypass-cookie', 'true'); + + const response = NextResponse.redirect(redirectTarget.toString(), 302); + response.headers.set('Cache-Control', 'no-store'); + response.headers.set('X-Robots-Tag', 'noindex, nofollow'); + return response; +} From 94722eb0282a36f7bc853638b4a305a535fe71cf Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 12 Aug 2026 14:16:51 -0700 Subject: [PATCH 2/2] fix(preview-share): harden endpoint against production exposure - robots.txt: Disallow /api/preview-share (belt-and-suspenders on top of the existing X-Robots-Tag: noindex on every response). - Endpoint: hard-reject the production `git-master` build alias, so even a valid signature can never redirect to the production build URL. The workflow already only signs non-production deployments and the signature binds each link to a specific host; this is defense-in-depth for the production lockdown. - Add a regression test for the master-alias guard. - README: clarify the endpoint is a redirector (no preview content on docs.sentry.io), harmless without a valid link, hidden from nav/sitemap, and cannot mint links to production URLs. --- app/api/preview-share/README.md | 12 +++++++++++- app/api/preview-share/route.test.ts | 7 +++++++ app/api/preview-share/route.ts | 9 +++++++++ app/robots.txt/route.ts | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/api/preview-share/README.md b/app/api/preview-share/README.md index 69fc55c173f1a..f7abc395891f8 100644 --- a/app/api/preview-share/README.md +++ b/app/api/preview-share/README.md @@ -73,5 +73,15 @@ and the GitHub Actions secret (existing links stop working immediately). - Allowed preview hosts: `sentry-docs*` / `develop-docs*` on `.sentry.dev` or `.vercel.app`. Anything else is rejected by the endpoint. -- The endpoint is `noindex` and `no-store`. +- The endpoint only ever redirects to a **preview** host. Each link's signature + is bound to a specific host, and the workflow only signs non-production + deployments, so no valid link to a production build URL can be minted. As an + extra guard, the endpoint also hard-rejects the production `git-master` build + alias. +- The endpoint is a redirector only — no preview content is served from + `docs.sentry.io`. Hitting it without a valid signed link returns a harmless + `400`. +- The endpoint is `noindex`/`no-store` on every response and is also + `Disallow`ed in `robots.txt`. It is not linked from any page, nav, or the + sitemap — it only appears in the PR comment. - Tests: `pnpm test app/api/preview-share`. diff --git a/app/api/preview-share/route.test.ts b/app/api/preview-share/route.test.ts index 970a4eca834a5..6f778e8e65053 100644 --- a/app/api/preview-share/route.test.ts +++ b/app/api/preview-share/route.test.ts @@ -109,6 +109,13 @@ describe('preview-share route', () => { expect(res.status).toBe(400); }); + it('refuses the production (master) build alias even with a valid signature', () => { + const u = 'https://sentry-docs-git-master-getsentry.vercel.app'; + const exp = FUTURE(); + const res = GET(buildRequest({u, exp, sig: sign(u, exp)})); + expect(res.status).toBe(400); + }); + it('returns 400 for a non-https target', () => { const u = 'http://sentry-docs-git-my-branch.sentry.dev'; const exp = FUTURE(); diff --git a/app/api/preview-share/route.ts b/app/api/preview-share/route.ts index d2f2b93f2e523..dbfd5b2cc8174 100644 --- a/app/api/preview-share/route.ts +++ b/app/api/preview-share/route.ts @@ -31,6 +31,12 @@ export const dynamic = 'force-dynamic'; const ALLOWED_PREVIEW_HOST = /^(sentry-docs|develop-docs)[a-z0-9-]*\.(sentry\.dev|vercel\.app)$/; +// Defense-in-depth: never redirect to the production (default-branch) build +// alias, even if a valid signature somehow existed for it. The workflow only +// ever signs non-production deployments, so this should never match in +// practice — it's a belt-and-suspenders guard for the production lockdown. +const PRODUCTION_BRANCH_ALIAS = /(^|[.-])git-master([-.])/; + function timingSafeStrEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); @@ -114,6 +120,9 @@ export function GET(request: NextRequest): NextResponse { if (target.protocol !== 'https:' || !ALLOWED_PREVIEW_HOST.test(target.host)) { return errorPage('This preview link points to a host that is not allowed.', 400); } + if (PRODUCTION_BRANCH_ALIAS.test(target.host)) { + return errorPage('This preview link points to a production build URL.', 400); + } // Pick the correct project's bypass secret based on the host. The host is // covered by the signature, so this decision is integrity-protected. diff --git a/app/robots.txt/route.ts b/app/robots.txt/route.ts index d01ef2dfac6ad..ec515c75152db 100644 --- a/app/robots.txt/route.ts +++ b/app/robots.txt/route.ts @@ -11,6 +11,7 @@ Sitemap: ${sitemap} User-agent: * Allow: / +Disallow: /api/preview-share Content-Signal: ai-train=yes, search=yes, ai-input=yes `.trim(), {headers: {'content-type': 'text/plain'}}