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()
|
||||
})
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
@@ -248,7 +248,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
|
||||
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
|
||||
method: 'POST', body, signal: controller.signal,
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
|
||||
}))
|
||||
controller.abort()
|
||||
const parsed = await (await pending).json() as { result: { error?: { code: string } } }
|
||||
@@ -260,18 +260,18 @@ describe('handler carrier-layer statuses', () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
|
||||
it('404s unknown paths and non-POST non-stream methods', async () => {
|
||||
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('400s a non-JSON body', async () => {
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(body.rpcId).toBe('invalid-request')
|
||||
@@ -280,7 +280,7 @@ describe('handler carrier-layer statuses', () => {
|
||||
|
||||
it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
|
||||
expect(parsed.rpcId).toBe('r-9')
|
||||
expect(parsed.result.error?.message).toContain('does not match path')
|
||||
@@ -288,7 +288,7 @@ describe('handler carrier-layer statuses', () => {
|
||||
|
||||
it('rejects an invalid payload with the zod issues attached', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
|
||||
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
|
||||
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
|
||||
expect(parsed.result.error?.code).toBe('bad-request')
|
||||
expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
|
||||
@@ -297,23 +297,23 @@ describe('handler carrier-layer statuses', () => {
|
||||
it('500s when the impl itself throws', async () => {
|
||||
const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
|
||||
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
|
||||
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('impl crashed')
|
||||
})
|
||||
|
||||
it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
|
||||
const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
|
||||
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
|
||||
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json()
|
||||
expect(goodReceipt).toEqual({ accepted: true })
|
||||
const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
|
||||
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
|
||||
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json()
|
||||
expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
})
|
||||
|
||||
it('accepts (url, init) form fetch invocation', async () => {
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
|
||||
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
|
||||
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user