Preview link unavailable
+${message}
+Push a new commit to the pull request (or re-run the deployment) to get a fresh preview link.
+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..f7abc395891f8 --- /dev/null +++ b/app/api/preview-share/README.md @@ -0,0 +1,87 @@ +# 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 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 new file mode 100644 index 0000000000000..6f778e8e65053 --- /dev/null +++ b/app/api/preview-share/route.test.ts @@ -0,0 +1,133 @@ +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('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(); + 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..dbfd5b2cc8174 --- /dev/null +++ b/app/api/preview-share/route.ts @@ -0,0 +1,149 @@ +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)$/; + +// 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); + if (ab.length !== bb.length) { + return false; + } + return timingSafeEqual(ab, bb); +} + +function errorPage(message: string, status: number): NextResponse { + const html = ` + +
+ + + +${message}
+Push a new commit to the pull request (or re-run the deployment) to get a fresh preview link.
+