Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/web/src/app/api/trpc/[trpc]/__tests__/route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest } from 'next/server'

import * as route from '../route'

const buildPost = (fetchSite: string) =>
new NextRequest('http://localhost:3000/api/trpc/me.get', {
method: 'POST',
headers: {
'content-type': 'application/json',
'sec-fetch-site': fetchSite,
},
body: '{}',
})

describe('tRPC route', () => {
it('rejects a cross-site POST with 403', async () => {
const response = await route.POST(buildPost('same-site'))

expect(response.status).toBe(403)
})

it('passes a same-origin POST through to tRPC', async () => {
const response = await route.POST(buildPost('same-origin'))

expect(response.status).not.toBe(403)
})

it('does not advertise CORS', async () => {
const response = await route.POST(buildPost('same-origin'))

expect(response.headers.get('access-control-allow-origin')).toBeNull()
expect(route).not.toHaveProperty('OPTIONS')
})
})
25 changes: 5 additions & 20 deletions apps/web/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,13 @@ import { APP_VERSION_HEADER_KEY } from '~/constants'
import { env } from '~/env'
import { appRouter } from '~/server/api/root'
import { createTRPCContext } from '~/server/api/trpc'

/**
* Configure basic CORS headers
* You should extend this to match your needs
*/
const setCorsHeaders = (res: Response) => {
res.headers.set('Access-Control-Allow-Origin', '*')
res.headers.set('Access-Control-Request-Method', '*')
res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST')
res.headers.set('Access-Control-Allow-Headers', '*')
}

export const OPTIONS = () => {
const response = new Response(null, {
status: 204,
})
setCorsHeaders(response)
return response
}
import { isCrossSiteRequest } from '~/server/utils/request'

const handler = async (req: NextRequest) => {
if (isCrossSiteRequest(req)) {
return new Response('Forbidden', { status: 403 })
}

const response = await fetchRequestHandler({
endpoint: '/api/trpc',
router: appRouter,
Expand All @@ -45,7 +31,6 @@ const handler = async (req: NextRequest) => {
},
})

setCorsHeaders(response)
if (env.NEXT_PUBLIC_APP_VERSION) {
response.headers.set(APP_VERSION_HEADER_KEY, env.NEXT_PUBLIC_APP_VERSION)
}
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/server/utils/__tests__/request.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { isCrossSiteRequest } from '../request'

const buildRequest = (method: string, fetchSite?: string) =>
new Request('http://localhost:3000/api/trpc/me.get', {
method,
headers: fetchSite ? { 'sec-fetch-site': fetchSite } : {},
})

describe('isCrossSiteRequest', () => {
it.each(['cross-site', 'same-site'])(
'flags a POST with sec-fetch-site %s',
(fetchSite) => {
expect(isCrossSiteRequest(buildRequest('POST', fetchSite))).toBe(true)
}
)

it.each(['same-origin', 'none'])(
'allows a POST with sec-fetch-site %s',
(fetchSite) => {
expect(isCrossSiteRequest(buildRequest('POST', fetchSite))).toBe(false)
}
)

it('allows a POST without sec-fetch-site from non-browser clients', () => {
expect(isCrossSiteRequest(buildRequest('POST'))).toBe(false)
})

it.each(['GET', 'HEAD'])('allows a cross-site %s', (method) => {
expect(isCrossSiteRequest(buildRequest(method, 'cross-site'))).toBe(false)
})
})
16 changes: 16 additions & 0 deletions apps/web/src/server/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,19 @@ export const extractIpAddress = (headers: Headers): string | null => {

return forwarded.split(/, /)[0] ?? null
}

const SAFE_METHODS = new Set(['GET', 'HEAD'])
const TRUSTED_FETCH_SITES = new Set(['same-origin', 'none'])

export const isCrossSiteRequest = (request: Request): boolean => {
if (SAFE_METHODS.has(request.method)) {
return false
}

const fetchSite = request.headers.get('sec-fetch-site')
if (fetchSite === null) {
return false
}

return !TRUSTED_FETCH_SITES.has(fetchSite)
}
Loading