mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
fix(studio): handle invalid TanStack API request bodies
This commit is contained in:
1 parent
13de8189c9
commit
6cd65d2b8a
2 files changed
+244
-8
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toWebHandler } from './api'
|
||||
|
||||
const createRequest = (body?: string, contentType = 'application/json') =>
|
||||
new Request('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': contentType },
|
||||
body,
|
||||
})
|
||||
|
||||
describe('toWebHandler request parsing', () => {
|
||||
it.each(
|
||||
['application/json', 'application/ld+json', 'Application/JSON; charset=utf-8'].flatMap(
|
||||
(contentType) =>
|
||||
['{', '[1,', 'undefined', ' ', '{"private":"value",}'].map((body) => ({
|
||||
contentType,
|
||||
body,
|
||||
}))
|
||||
)
|
||||
)(
|
||||
'returns 400 without calling the handler for malformed $contentType: $body',
|
||||
async ({ body, contentType }) => {
|
||||
const handler = vi.fn()
|
||||
const response = await toWebHandler(handler)({ request: createRequest(body, contentType) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.text()).toBe('Invalid JSON')
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each<[string | undefined, unknown]>([
|
||||
['{"query":"select 1"}', { query: 'select 1' }],
|
||||
['[1,"two",null]', [1, 'two', null]],
|
||||
['"hello"', 'hello'],
|
||||
['42', 42],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['null', null],
|
||||
['', {}],
|
||||
[undefined, {}],
|
||||
])('passes valid JSON %s to the handler', async (body, expected) => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
|
||||
expect(req.body).toEqual(expected)
|
||||
res.status(201).setHeader('x-handler', 'called')
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
const response = await toWebHandler(handler)({ request: createRequest(body) })
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(response.status).toBe(201)
|
||||
expect(response.headers.get('x-handler')).toBe('called')
|
||||
expect(response.headers.get('content-type')).toBe('application/json')
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it.each(['application/json; charset=utf-8', 'application/ld+json', 'Application/JSON'])(
|
||||
'accepts JSON content type %s',
|
||||
async (contentType) => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => res.json(req.body))
|
||||
const response = await toWebHandler(handler)({
|
||||
request: createRequest('{"name":"café"}', contentType),
|
||||
})
|
||||
|
||||
expect(await response.json()).toEqual({ name: 'café' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each<[string, string, unknown]>([
|
||||
[
|
||||
'name=hello+world&value=%26%3D',
|
||||
'application/x-www-form-urlencoded',
|
||||
{
|
||||
name: 'hello world',
|
||||
value: '&=',
|
||||
},
|
||||
],
|
||||
['{', 'text/plain', '{'],
|
||||
['{', 'text/plain; note="application/json"', '{'],
|
||||
['{', 'application/jsonx', '{'],
|
||||
])('preserves non-JSON request bodies', async (body, contentType, expected) => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
|
||||
expect(req.body).toEqual(expected)
|
||||
res.end('ok')
|
||||
})
|
||||
|
||||
const response = await toWebHandler(handler)({ request: createRequest(body, contentType) })
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(await response.text()).toBe('ok')
|
||||
})
|
||||
|
||||
it.each(['GET', 'HEAD'])('leaves %s bodies undefined', async (method) => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
|
||||
expect(req.method).toBe(method)
|
||||
expect(req.body).toBeUndefined()
|
||||
res.end()
|
||||
})
|
||||
|
||||
await toWebHandler(handler)({
|
||||
request: new Request('http://localhost/api/test', {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves the URL, headers and route parameter precedence', async () => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
|
||||
expect(req.url).toBe('/api/test?ref=search&tag=one&tag=two&empty=')
|
||||
expect(req.headers['x-custom']).toBe('value')
|
||||
expect(req.query).toEqual({ ref: 'route', tag: ['one', 'two'], empty: '' })
|
||||
res.end('ok')
|
||||
})
|
||||
|
||||
await toWebHandler(handler)({
|
||||
request: new Request('http://localhost/api/test?ref=search&tag=one&tag=two&empty=', {
|
||||
headers: { 'X-Custom': 'value' },
|
||||
}),
|
||||
params: { ref: 'route', omitted: undefined },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['%', '%ZZ', '%E0%A4%A', '%FF'])(
|
||||
'preserves undecodable cookie values: %s',
|
||||
async (bad) => {
|
||||
const handler = vi.fn((req: NextApiRequest, res: NextApiResponse) => {
|
||||
expect(req.cookies).toEqual({ bad, good: 'hello world', token: 'a=b', empty: '' })
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
const response = await toWebHandler(handler)({
|
||||
request: new Request('http://localhost/api/test', {
|
||||
headers: { cookie: `bad=${bad}; good=hello%20world; token=a=b; empty=` },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not hide a body-read failure as invalid JSON', async () => {
|
||||
const request = createRequest('{}')
|
||||
const error = new Error('Body read failed')
|
||||
vi.spyOn(request, 'text').mockRejectedValue(error)
|
||||
const handler = vi.fn()
|
||||
|
||||
await expect(toWebHandler(handler)({ request })).rejects.toBe(error)
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not turn a handler SyntaxError into a request parsing error', async () => {
|
||||
const error = new SyntaxError('Handler failed')
|
||||
const handler = vi.fn(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await expect(toWebHandler(handler)({ request: createRequest('{}') })).rejects.toBe(error)
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes through a Web Response returned by the handler', async () => {
|
||||
const expected = new Response('direct', { status: 202, headers: { 'x-direct': 'yes' } })
|
||||
|
||||
const response = await toWebHandler(() => expected)({ request: createRequest('{}') })
|
||||
|
||||
expect(response).toBe(expected)
|
||||
expect(await response.text()).toBe('direct')
|
||||
})
|
||||
|
||||
it('keeps streamed responses open for chunks written after the handler returns', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
const response = await toWebHandler((_req, res) => {
|
||||
res.writeHead(202, { 'content-type': 'text/event-stream', 'x-stream': 'yes' })
|
||||
res.write('first\n')
|
||||
finish = () => res.end('last\n')
|
||||
})({ request: createRequest('{}') })
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(response.headers.get('x-stream')).toBe('yes')
|
||||
const reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
expect(decoder.decode((await reader.read()).value)).toBe('first\n')
|
||||
finish!()
|
||||
expect(decoder.decode((await reader.read()).value)).toBe('last\n')
|
||||
expect((await reader.read()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves close and aborted events from the request signal', async () => {
|
||||
const controller = new AbortController()
|
||||
const onClose = vi.fn()
|
||||
const onAborted = vi.fn()
|
||||
await toWebHandler((req, res) => {
|
||||
req.on('close', onClose)
|
||||
req.once('aborted', onAborted)
|
||||
res.end('ok')
|
||||
})({ request: new Request('http://localhost/api/test', { signal: controller.signal }) })
|
||||
|
||||
controller.abort()
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(onAborted).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -20,9 +20,19 @@ interface RouteCtx {
|
||||
params?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
class InvalidJsonError extends Error {}
|
||||
|
||||
export function toWebHandler(handler: NextHandler) {
|
||||
return async ({ request, params = {} }: RouteCtx): Promise<Response> => {
|
||||
const req = await buildRequest(request, params)
|
||||
let req: NextApiRequest
|
||||
try {
|
||||
req = await buildRequest(request, params)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidJsonError) {
|
||||
return new Response('Invalid JSON', { status: 400, statusText: 'Invalid JSON' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { res, finalize } = buildResponse()
|
||||
|
||||
const result = await handler(req, res)
|
||||
@@ -64,7 +74,12 @@ async function buildRequest(
|
||||
const eq = trimmed.indexOf('=')
|
||||
const name = eq >= 0 ? trimmed.slice(0, eq) : trimmed
|
||||
const value = eq >= 0 ? trimmed.slice(eq + 1) : ''
|
||||
cookies[name] = decodeURIComponent(value)
|
||||
cookies[name] = value
|
||||
try {
|
||||
cookies[name] = decodeURIComponent(value)
|
||||
} catch {
|
||||
// Next's cookie parser keeps values that cannot be URI-decoded.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +97,23 @@ async function buildRequest(
|
||||
}
|
||||
|
||||
let body: unknown = undefined
|
||||
if (method !== 'GET' && method !== 'HEAD' && request.body) {
|
||||
const contentType = request.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
const contentType = (request.headers.get('content-type') ?? '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (contentType === 'application/json' || contentType === 'application/ld+json') {
|
||||
const text = await request.text()
|
||||
body = text ? JSON.parse(text) : undefined
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
try {
|
||||
// Next treats an empty JSON body as an empty object.
|
||||
body = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
throw new InvalidJsonError('Invalid JSON')
|
||||
}
|
||||
} else if (request.body && contentType === 'application/x-www-form-urlencoded') {
|
||||
const text = await request.text()
|
||||
body = Object.fromEntries(new URLSearchParams(text))
|
||||
} else {
|
||||
} else if (request.body) {
|
||||
body = await request.text()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user