mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 16:19:10 +03:00
fix(studio): handle invalid TanStack API request bodies (#51046)
Malformed JSON now returns `400 Invalid JSON` from the TanStack API
adapter instead of an unhandled 500. Empty JSON bodies and undecodable
cookies follow the existing Next parser behavior.
**Changed:**
- Handle JSON parsing failures before invoking the API handler, while
preserving body-read and downstream handler errors.
- Recognize exact JSON and JSON-LD media types, including case and
charset parameters.
- Keep raw cookie values when URI decoding fails.
**Added:**
- 43 adapter tests covering valid and invalid bodies, cookies, handler
isolation, response headers, streaming, and abort events.
## To test
- POST malformed JSON to `/api/parse-query` with `Content-Type:
application/json`; expect 400 with `Invalid JSON`. Repeat with JSON-LD.
- POST `{"sql":"select 1"}` to the same endpoint; expect 200. This
parses SQL without executing it against a database.
- Repeat the valid request with an undecodable cookie value; expect the
same successful result.
- Open an existing project and database settings; verify normal API
consumers remain usable.
Validation: 59 focused tests, 29 differential body cases plus malformed
cookies against installed Next parsers, Studio typecheck, lint ratchet,
scoped ESLint, formatting, knip, and both framework production builds
passed. Direct local HTTP checks passed for malformed, empty, valid, and
malformed-cookie requests. Next handlers, routes, environment files, and
dependencies are unchanged. Local TanStack browser checks passed for
signed-in project rendering, clean reload, and the untouched support
form. Database settings rendered its error state, but the banned-IP
service returned an upstream connection-timeout payload, so successful
list coverage remains unverified. Chrome blocked direct API-document
navigation; malformed-input behavior is covered by the separate HTTP and
parser checks.
The automatic staging preview ran native Next (confirmed from its build
output), providing an additional Next regression check: malformed JSON
and JSON-LD returned exact `400 Invalid JSON`; valid SQL parsing
returned 200; an undecodable cookie preserved the same valid response.
No SQL was executed. The local TanStack runtime and in-process adapter
checks passed; the combined TanStack deployment results follow.
The combined rollout preview (TanStack, QA commit
`f35b3c42d8ba6a714299b148d797b09107a7a6fe`) also passes deployed
malformed JSON/JSON-LD 400, valid SQL parsing 200, and
undecodable-cookie 200 with the same valid response. No SQL is executed
against a database.
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
1 parent
6c150d26d9
commit
820ff0f2d2
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