fix(apiproxy): refuse non-JSON media types on /api POST bodies
Browsers send "simple" POSTs (text/plain, form encodings) without a CORS preflight, so a malicious page could execute side-effectful RPCs blind — the response stays unreadable cross-origin, but session.prompt would still run. The carrier now answers 415 unless the declared media type is application/json, forcing every cross-site attempt into a preflight this server never answers. Raw-fetch specs gain the header; a new handler case proves the fence rejects before the impl runs.
This commit is contained in:
@@ -138,7 +138,7 @@ describe('unary round trip', () => {
|
||||
it('rejects a method/path mismatch as bad-request', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
|
||||
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
|
||||
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
expect(response.status).toBe(200)
|
||||
const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
|
||||
expect(parsed.result.ok).toBe(false)
|
||||
@@ -149,13 +149,13 @@ describe('unary round trip', () => {
|
||||
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
|
||||
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
|
||||
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
|
||||
expect(noId.status).toBe(200)
|
||||
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
|
||||
expect(noIdParsed.result.ok).toBe(false)
|
||||
expect(noIdParsed.rpcId).toBe('invalid-request')
|
||||
// A string rpcId in the otherwise-bad body is salvaged for correlation.
|
||||
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
|
||||
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
|
||||
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
|
||||
expect(withIdParsed.result.ok).toBe(false)
|
||||
expect(withIdParsed.rpcId).toBe('salvage-me')
|
||||
@@ -164,16 +164,34 @@ describe('unary round trip', () => {
|
||||
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
// Unknown method → 404.
|
||||
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
|
||||
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||
expect(notFound.status).toBe(404)
|
||||
// Non-JSON body → 400.
|
||||
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
|
||||
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
|
||||
expect(badBody.status).toBe(400)
|
||||
// Impl crash → 500, and through the client that is a throw, not an err result.
|
||||
const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
|
||||
await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
|
||||
})
|
||||
|
||||
it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
|
||||
const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
|
||||
const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
|
||||
// A "simple" browser POST (text/plain — sent with no CORS preflight) is
|
||||
// refused at the carrier before the impl runs.
|
||||
const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
|
||||
expect(plain.status).toBe(415)
|
||||
// A string body with no explicit header defaults to text/plain — same fence.
|
||||
const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
|
||||
expect(unlabelled.status).toBe(415)
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
// Media-type parameters pass: the fence checks the type, not the exact string.
|
||||
const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
|
||||
expect(charset.status).toBe(200)
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects when the transport never resolves within timeoutMs', async () => {
|
||||
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
|
||||
const never = new InProcessApiClient({
|
||||
@@ -421,7 +439,7 @@ describe('respond path', () => {
|
||||
it('returns bad-response for a malformed client-response without reaching the impl', async () => {
|
||||
const respond = vi.fn()
|
||||
const handler = toFetchHandler(scriptedApi({ respond }))
|
||||
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
|
||||
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
|
||||
expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user