diff --git a/app/api/auth/siwe/verify/route.ts b/app/api/auth/siwe/verify/route.ts index d0b85ad4..f1149add 100644 --- a/app/api/auth/siwe/verify/route.ts +++ b/app/api/auth/siwe/verify/route.ts @@ -54,6 +54,11 @@ export async function POST(req: Request): Promise { } return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); } + // JSON の `null` / 数値 / 文字列も parse は成功する。object でなければ欄を読む前に 400 で返す + // (null のまま欄を読むと TypeError → 500 + Sentry event になっていた・第 6 回 B-R6f)。 + if (cappedBody.value === null || typeof cappedBody.value !== 'object') { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } const body = cappedBody.value as { message?: unknown; signature?: unknown }; // domain 束縛はサーバ制御の許可リストで判定する (Host ヘッダは偽装可能なので使わない — diff --git a/app/api/billing/settle/route.ts b/app/api/billing/settle/route.ts index e083f830..6f122a5b 100644 --- a/app/api/billing/settle/route.ts +++ b/app/api/billing/settle/route.ts @@ -120,6 +120,11 @@ export async function POST(req: Request): Promise { { status: 400 }, ); } + // JSON の `null` も parse は成功し、分解代入で TypeError → 500 になっていた (第 6 回 B-R6f)。money-path なので + // null だけを 400 にする (文字列/数値の本文は従来どおり invalid_chain・pro/subscribe と同じ線)。 + if (capped.value === null) { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } // 既存の分解代入・検証順序は不変に保つ (掟12: money-path は追加のみ)。 const body = capped.value as { txHash?: unknown; diff --git a/app/api/csv-pass/relay/route.ts b/app/api/csv-pass/relay/route.ts index 55cc2bb6..d5e67a0c 100644 --- a/app/api/csv-pass/relay/route.ts +++ b/app/api/csv-pass/relay/route.ts @@ -104,6 +104,11 @@ export async function POST(req: Request): Promise { } catch { return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); } + // JSON.parse は `null` / 数値 / 文字列も返す。`null` のまま raw.chainId を読むと TypeError → 500 + Sentry + // になっていた (第 6 回 B-R6f)。object 以外は欄不足と同じ invalid_payload で 400 (relay/status も object 以外を拒否する・register/claim は invalid_body で配列も拒否)。 + if (raw === null || typeof raw !== 'object') { + return NextResponse.json({ ok: false, error: 'invalid_payload' }, { status: 400 }); + } if (typeof raw.chainId !== 'number' || !Number.isInteger(raw.chainId)) { return NextResponse.json({ ok: false, error: 'invalid_payload' }, { status: 400 }); diff --git a/app/api/freee/mapping/route.ts b/app/api/freee/mapping/route.ts index 6f558415..fc312fcf 100644 --- a/app/api/freee/mapping/route.ts +++ b/app/api/freee/mapping/route.ts @@ -65,6 +65,11 @@ export async function POST(req: Request): Promise { } catch { return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); } + // JSON の `null` / 数値 / 文字列も parse は成功する。object でなければ欄を読む前に 400 で返す + // (null のまま欄を読むと TypeError → 500 + Sentry event になっていた・第 6 回 B-R6f)。 + if (body === null || typeof body !== 'object') { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } if ( typeof body.accountItemId !== 'number' || typeof body.taxCode !== 'number' diff --git a/app/api/freee/sync/route.ts b/app/api/freee/sync/route.ts index 212361f8..c54bd8e0 100644 --- a/app/api/freee/sync/route.ts +++ b/app/api/freee/sync/route.ts @@ -59,6 +59,11 @@ export async function POST(req: Request): Promise { } catch { return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); } + // JSON の `null` / 数値 / 文字列も parse は成功する。object でなければ欄を読む前に 400 で返す + // (null のまま欄を読むと TypeError → 500 + Sentry event になっていた・第 6 回 B-R6f)。 + if (body === null || typeof body !== 'object') { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } const entries = Array.isArray(body.entries) ? (body.entries as HistoryEntry[]) : []; if (entries.length === 0) { return NextResponse.json({ ok: false, error: 'no_entries' }, { status: 400 }); diff --git a/app/api/relay/jpyc/route.ts b/app/api/relay/jpyc/route.ts index b9900535..74212b2b 100644 --- a/app/api/relay/jpyc/route.ts +++ b/app/api/relay/jpyc/route.ts @@ -220,6 +220,11 @@ export async function POST(req: Request): Promise { } catch { return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); } + // JSON.parse は `null` / 数値 / 文字列も返す。`null` のまま raw.chainId を読むと TypeError → 500 + Sentry + // になっていた (第 6 回 B-R6f)。object 以外は欄不足と同じ invalid_payload で 400 (relay/status も object 以外を拒否する・register/claim は invalid_body で配列も拒否)。 + if (raw === null || typeof raw !== 'object') { + return NextResponse.json({ ok: false, error: 'invalid_payload' }, { status: 400 }); + } if (typeof raw.chainId !== 'number' || !Number.isInteger(raw.chainId)) { return NextResponse.json({ ok: false, error: 'invalid_payload' }, { status: 400 }); diff --git a/tests/app/api/auth-siwe.test.ts b/tests/app/api/auth-siwe.test.ts index ae4eec07..1f0a5dbd 100644 --- a/tests/app/api/auth-siwe.test.ts +++ b/tests/app/api/auth-siwe.test.ts @@ -237,6 +237,18 @@ describe('SIWE routes', () => { expect(h.kvSet).toHaveBeenCalledOnce(); }); + it('verify: 本文が JSON の null → 400 invalid_json (verify_failed の 503 + Sentry にしない・B-R6f)', async () => { + h.kvConfigured = true; + const headers = new Headers({ 'content-type': 'application/json' }); + headers.set('sec-fetch-site', 'same-origin'); + const res = await verifyPOST(new Request('http://localhost/api/auth/siwe/verify', { + method: 'POST', headers, body: 'null', + })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'invalid_json' }); + expect(h.kvSet).not.toHaveBeenCalled(); + }); + it('verify: JSON-shaped bytes without Content-Type still fail before session writes', async () => { h.kvConfigured = true; const res = await verifyPOST(new Request('http://localhost/api/auth/siwe/verify', { diff --git a/tests/app/api/billing-settle.test.ts b/tests/app/api/billing-settle.test.ts index 3ba963d8..dd7a4994 100644 --- a/tests/app/api/billing-settle.test.ts +++ b/tests/app/api/billing-settle.test.ts @@ -358,6 +358,12 @@ describe('POST /api/billing/settle', () => { expect(await res.json()).toMatchObject({ error: 'invalid_json' }); }); + it('本文が JSON の null → 400 invalid_json (分解代入の TypeError で 500 にしない・B-R6f)', async () => { + const res = await POST(req(null)); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: 'invalid_json' }); + }); + it('body 上限超過 → JSON parse 前に 413 payload_too_large', async () => { const huge = new Request('http://localhost/api/billing/settle', { method: 'POST', diff --git a/tests/app/api/csv-pass-relay.test.ts b/tests/app/api/csv-pass-relay.test.ts index 98589d44..329af41c 100644 --- a/tests/app/api/csv-pass-relay.test.ts +++ b/tests/app/api/csv-pass-relay.test.ts @@ -253,6 +253,12 @@ describe('POST /api/csv-pass/relay — route 固有検証', () => { expect(res.status).toBe(400); expect(await res.json()).toEqual({ ok: false, error: 'invalid_payload' }); }); + + it('本文が JSON の null → 400 invalid_payload (TypeError の 500 にしない・B-R6f)', async () => { + const res = await POST(req(null)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'invalid_payload' }); + }); }); describe('POST /api/csv-pass/relay — relay 結果の応答整形 + guards 配線', () => { diff --git a/tests/app/api/freee-routes-integration.test.ts b/tests/app/api/freee-routes-integration.test.ts index 8a05275b..12f0fb8d 100644 --- a/tests/app/api/freee-routes-integration.test.ts +++ b/tests/app/api/freee-routes-integration.test.ts @@ -380,6 +380,23 @@ describe('GET/POST /api/freee/mapping (実グルー)', () => { expect(h.store.has(mappingKey)).toBe(false); }); + it('POST: 本文が JSON の null → 400 invalid_json (TypeError の 500 にしない・B-R6f)', async () => { + seedSession(); + seedFreeeConnected(); + const res = await mappingPOST(req('http://localhost/api/freee/mapping', null)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'invalid_json' }); + }); + + it('sync: 本文が JSON の null → 400 invalid_json (TypeError の 500 にしない・B-R6f)', async () => { + seedSession(); + seedFreeeConnected(); + seedMapping(); + const res = await syncPOST(req('http://localhost/api/freee/sync', null)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'invalid_json' }); + }); + it('POST: 不正 body → 400 invalid_mapping', async () => { seedSession(); seedFreeeConnected(); diff --git a/tests/app/api/relay-jpyc.test.ts b/tests/app/api/relay-jpyc.test.ts index b77b1846..b9b83051 100644 --- a/tests/app/api/relay-jpyc.test.ts +++ b/tests/app/api/relay-jpyc.test.ts @@ -63,6 +63,15 @@ describe('POST /api/relay/jpyc (env-gate)', () => { const body = (await res.json()) as { error?: string }; expect(body.error).toBe('invalid_payload'); }); + + it('本文が JSON の null → 400 invalid_payload (raw.chainId の TypeError で 500 にしない・B-R6f)', async () => { + vi.stubEnv(EIP3009_ON, '1'); + vi.stubEnv('RELAYER_PRIVATE_KEY', DUMMY_RELAYER_KEY); + const mod = await import('@/app/api/relay/jpyc/route'); + const res = await mod.POST(req(null)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: 'invalid_payload' }); + }); }); // recover (forwarder) × a1 (usage fee) の排他は resolver (jpycForwarderFor) で graceful に解決する: