diff --git a/lib/app-router/handlers/enable-draft.spec.ts b/lib/app-router/handlers/enable-draft.spec.ts index 141bb71..d1f7907 100644 --- a/lib/app-router/handlers/enable-draft.spec.ts +++ b/lib/app-router/handlers/enable-draft.spec.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { redirect } from 'next/navigation'; import { NextRequest } from 'next/server'; import { enableDraftHandler as GET } from './enable-draft'; +import { draftMode } from 'next/headers'; vi.mock('next/navigation', () => { return { @@ -11,14 +12,10 @@ vi.mock('next/navigation', () => { vi.mock('next/headers', () => { return { - draftMode: vi.fn(() => draftModeMock), + draftMode: vi.fn(), }; }); -const draftModeMock = { - enable: vi.fn(), -}; - describe('handler', () => { const bypassToken = 'kByQez2ke5Jl4ulCY6kxQrpFMp1UIohs'; const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; @@ -29,10 +26,22 @@ describe('handler', () => { let request: NextRequest = new NextRequest(url); request.cookies.set('_vercel_jwt', vercelJwt); + let draftModeMock: ReturnType + beforeEach(() => { vi.stubEnv('VERCEL_AUTOMATION_BYPASS_SECRET', bypassToken); + draftModeMock = { + enable: vi.fn(), + disable: vi.fn(), + isEnabled: true + } + vi.mocked(draftMode).mockReturnValue(draftModeMock) }); + afterEach(() => { + vi.resetAllMocks() + }) + it('redirects safely to the provided path, without passing through the token and bypass cookie query params', async () => { const result = await GET(request); expect(result).to.be.undefined; @@ -68,6 +77,23 @@ describe('handler', () => { }); }); + describe('when a x-vercel-protection-bypass token is also provided as a query param', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat&x-vercel-protection-bypass=${bypassToken}`; + request = new NextRequest(url); + request.cookies.set('_vercel_jwt', vercelJwt); + }); + + it('redirects safely to the provided path and DOES NOT pass through the token and bypass cookie query params', async () => { + const result = await GET(request); + expect(result).to.be.undefined; + expect(draftModeMock.enable).toHaveBeenCalled(); + expect(vi.mocked(redirect)).toHaveBeenCalledWith( + `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat`, + ); + }); + }); + describe('when the _vercel_jwt cookie is missing', () => { beforeEach(() => { const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; diff --git a/lib/app-router/handlers/enable-draft.ts b/lib/app-router/handlers/enable-draft.ts index 4755a15..8a48f20 100644 --- a/lib/app-router/handlers/enable-draft.ts +++ b/lib/app-router/handlers/enable-draft.ts @@ -22,23 +22,30 @@ export async function enableDraftHandler( return redirect(redirectUrl); } + const vercelJwtCookie = getVercelJwtCookie(request) + let bypassToken: string; let aud: string; + let vercelJwt: VercelJwt | null = null if (bypassTokenFromQuery) { bypassToken = bypassTokenFromQuery; aud = host; } else { - // if x-vercel-protection-bypass not provided in query, we defer to parsing the _vercel_jwt cookie - // which bundlees the bypass token value in its payload - let vercelJwt: VercelJwt + // if we don't have a bypass token from the query we fall back to the _vercel_jwt cookie to find + // the correct authorization bypass elements + if (!vercelJwtCookie) { + return new Response( + 'Missing _vercel_jwt cookie required for authorization bypass', + { status: 401 }, + ); + } try { - const vercelJwtCookie = getVercelJwtCookie(request) vercelJwt = parseVercelJwtCookie(vercelJwtCookie); } catch (e) { if (!(e instanceof Error)) throw e; return new Response( - 'Missing or malformed bypass authorization token in _vercel_jwt cookie', + 'Malformed bypass authorization token in _vercel_jwt cookie', { status: 401 }, ); } @@ -68,6 +75,10 @@ export async function enableDraftHandler( draftMode().enable(); - const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + // if a _vercel_jwt cookie was found, we do _not_ want to pass through the bypassToken to the redirect query. this + // is because Vercel will not "process" (and remove) the query parameter when a _vercel_jwt cookie is present. + const bypassTokenForRedirect = vercelJwtCookie ? undefined : bypassTokenFromQuery + + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery: bypassTokenForRedirect }); redirect(redirectUrl); } diff --git a/lib/pages-router/handlers/enable-draft.spec.ts b/lib/pages-router/handlers/enable-draft.spec.ts index bf63424..2968436 100644 --- a/lib/pages-router/handlers/enable-draft.spec.ts +++ b/lib/pages-router/handlers/enable-draft.spec.ts @@ -85,6 +85,23 @@ describe('handler', () => { }); }); + describe('when a x-vercel-protection-bypass token is also provided as a query param', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat&x-vercel-protection-bypass=${bypassToken}`; + request = makeNextApiRequest(url); + request.cookies['_vercel_jwt'] = vercelJwt; + }); + + it('redirects safely to the provided path and DOES NOT pass through the token and bypass cookie query params', async () => { + const result = await handler(request, response); + expect(result).to.be.undefined; + expect(apiResponseSpy.setDraftMode).toHaveBeenCalledWith({ enable: true }); + expect(apiResponseSpy.redirect).toHaveBeenCalledWith( + `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat`, + ); + }); + }); + describe('when the _vercel_jwt cookie is missing', () => { beforeEach(() => { const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; diff --git a/lib/pages-router/handlers/enable-draft.ts b/lib/pages-router/handlers/enable-draft.ts index 6edbdb3..07883ef 100644 --- a/lib/pages-router/handlers/enable-draft.ts +++ b/lib/pages-router/handlers/enable-draft.ts @@ -1,6 +1,6 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; import { buildRedirectUrl, parseNextApiRequest } from '../../utils/url'; -import { parseVercelJwtCookie} from '../../utils/vercelJwt'; +import { parseVercelJwtCookie } from '../../utils/vercelJwt'; import { type VercelJwt } from '../../types'; export const enableDraftHandler: NextApiHandler = async ( @@ -22,6 +22,7 @@ export const enableDraftHandler: NextApiHandler = async ( return } + const vercelJwtCookie = request.cookies['_vercel_jwt'] let bypassToken: string; let aud: string; @@ -33,13 +34,17 @@ export const enableDraftHandler: NextApiHandler = async ( // which bundlees the bypass token value in its payload let vercelJwt: VercelJwt; try { - const vercelJwtCookie = request.cookies['_vercel_jwt'] - if (!vercelJwtCookie) throw new Error('`_vercel_jwt` cookie not set'); + if (!vercelJwtCookie) { + response.status(401).send( + 'Missing _vercel_jwt cookie required for authorization bypass' + ) + return + } vercelJwt = parseVercelJwtCookie(vercelJwtCookie); } catch (e) { if (!(e instanceof Error)) throw e; response.status(401).send( - 'Missing or malformed bypass authorization token in _vercel_jwt cookie' + 'Malformed bypass authorization token in _vercel_jwt cookie' ) return } @@ -70,7 +75,11 @@ export const enableDraftHandler: NextApiHandler = async ( response.setDraftMode({ enable: true }) - const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + // if a _vercel_jwt cookie was found, we do _not_ want to pass through the bypassToken to the redirect query. this + // is because Vercel will not "process" (and remove) the query parameter when a _vercel_jwt cookie is present. + const bypassTokenForRedirect = vercelJwtCookie ? undefined : bypassTokenFromQuery + + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery: bypassTokenForRedirect }); response.redirect(redirectUrl) return } diff --git a/lib/utils/vercelJwt.spec.ts b/lib/utils/vercelJwt.spec.ts index e7bc43d..58467cb 100644 --- a/lib/utils/vercelJwt.spec.ts +++ b/lib/utils/vercelJwt.spec.ts @@ -1,16 +1,32 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { getVercelJwtCookie, parseVercelJwtCookie } from './vercelJwt'; import { NextRequest } from 'next/server'; -describe('getVercelJwtCookie', () => { +describe.only('getVercelJwtCookie', () => { const url = 'http://example.com' - const request = new NextRequest(url); - request.cookies.set('_vercel_jwt', 'vercel-jwt'); + let request: NextRequest + + beforeEach(() => { + request = new NextRequest(url); + request.cookies.set('_vercel_jwt', 'vercel-jwt'); + }) it('returns the _vercel_jwt cookie', () => { const result = getVercelJwtCookie(request) expect(result).toEqual('vercel-jwt') }) + + describe('when cookie is not present', () => { + beforeEach(() => { + request = new NextRequest(url); + request.cookies.clear() + }) + + it('returns undefined', () => { + const result = getVercelJwtCookie(request) + expect(result).to.equal(undefined) + }) + }) }) describe('parseVercelJwtCookie', () => { diff --git a/lib/utils/vercelJwt.ts b/lib/utils/vercelJwt.ts index 44a34ec..c242746 100644 --- a/lib/utils/vercelJwt.ts +++ b/lib/utils/vercelJwt.ts @@ -1,9 +1,9 @@ import { NextRequest } from "next/server"; import { type VercelJwt } from "../types"; -export const getVercelJwtCookie = (request: NextRequest): string => { +export const getVercelJwtCookie = (request: NextRequest): string | undefined => { const vercelJwtCookie = request.cookies.get('_vercel_jwt'); - if (!vercelJwtCookie) throw new Error('`_vercel_jwt` cookie not set'); + if (!vercelJwtCookie) return; return vercelJwtCookie.value; }