From eb07caf45237d84c30335d1903434cbcdb115740 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 12:53:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(relay):=20=E6=9C=AC=E6=96=87=E3=81=8C?= =?UTF-8?q?=20JSON=20=E3=81=AE=20null=20=E3=81=AE=E3=81=A8=E3=81=8D=20rela?= =?UTF-8?q?y/jpyc=20=E3=81=A8=20csv-pass/relay=20=E3=81=8C=20500=20?= =?UTF-8?q?=E3=81=AB=E3=81=AA=E3=82=8B=E3=81=AE=E3=82=92=20400=20=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(B-R6f=E3=83=BB=E6=8E=9F=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 6 回レビュー R6b の characterization で判明。JSON.parse が null を返すと raw.chainId で TypeError → 500 + Sentry event になっていた。JSON.parse の直後に object 以外を 400 invalid_payload で返す検査を足す (追加のみ・register/claim と relay/status と同じ線)。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/api/csv-pass/relay/route.ts | 5 +++++ app/api/relay/jpyc/route.ts | 5 +++++ tests/app/api/csv-pass-relay.test.ts | 6 ++++++ tests/app/api/relay-jpyc.test.ts | 9 +++++++++ 4 files changed, 25 insertions(+) diff --git a/app/api/csv-pass/relay/route.ts b/app/api/csv-pass/relay/route.ts index 55cc2bb6..245a3613 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 (register/claim・relay/status と同じ線)。 + 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/relay/jpyc/route.ts b/app/api/relay/jpyc/route.ts index b9900535..3521a8ae 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 (register/claim・relay/status と同じ線)。 + 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/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/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 に解決する: From 85d2696e9bed3133201378428511ebd19d44767c Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 12:59:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(api):=20siwe/verify=E3=83=BBbilling/set?= =?UTF-8?q?tle=E3=83=BBfreee=20mapping/sync=20=E3=82=82=20JSON=20=E3=81=AE?= =?UTF-8?q?=20null=20=E6=9C=AC=E6=96=87=E3=82=92=20400=20=E3=81=A7?= =?UTF-8?q?=E8=BF=94=E3=81=99=20(B-R6f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューで同じ型 (parse 結果が null のまま欄を読む → TypeError) の route が 4 つ見つかった。 siwe/verify は未ログインで到達でき 503 verify_failed + Sentry になっていた。 いずれも既存の invalid_json と同じ 400 を、欄を読む前に返す (追加のみ)。 freee の 2 route は SIWE の後ろで inert のため、テストは siwe/billing の 2 件を追加。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/api/auth/siwe/verify/route.ts | 5 +++++ app/api/billing/settle/route.ts | 5 +++++ app/api/csv-pass/relay/route.ts | 2 +- app/api/freee/mapping/route.ts | 5 +++++ app/api/freee/sync/route.ts | 5 +++++ app/api/relay/jpyc/route.ts | 2 +- tests/app/api/auth-siwe.test.ts | 12 ++++++++++++ tests/app/api/billing-settle.test.ts | 6 ++++++ 8 files changed, 40 insertions(+), 2 deletions(-) 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..b2dc7329 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 は成功する。object でなければ欄を読む前に 400 で返す + // (null のまま欄を読むと TypeError → 500 + Sentry event になっていた・第 6 回 B-R6f)。 + if (capped.value === null || typeof capped.value !== 'object') { + 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 245a3613..d5e67a0c 100644 --- a/app/api/csv-pass/relay/route.ts +++ b/app/api/csv-pass/relay/route.ts @@ -105,7 +105,7 @@ export async function POST(req: Request): Promise { 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 (register/claim・relay/status と同じ線)。 + // になっていた (第 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 }); } 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 3521a8ae..74212b2b 100644 --- a/app/api/relay/jpyc/route.ts +++ b/app/api/relay/jpyc/route.ts @@ -221,7 +221,7 @@ export async function POST(req: Request): Promise { 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 (register/claim・relay/status と同じ線)。 + // になっていた (第 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 }); } 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', From 5d6e9b0d507ae204cc3820d50942703776d80605 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 13:01:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(billing):=20null=20=E6=9C=AC=E6=96=87?= =?UTF-8?q?=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=82=92=20null=20=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=81=AB=E7=B5=9E=E3=82=8A=20freee=20=E3=81=AE=20null?= =?UTF-8?q?=20=E6=9C=AC=E6=96=87=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(B-R6f=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E5=8F=8D=E6=98=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billing/settle は money-path なので、文字列/数値の本文の error code (invalid_chain) を変えず JSON の null だけを 400 invalid_json にする (pro/subscribe と同じ線)。freee mapping/sync は 既存の integration test の helper で null 本文 → 400 を固定した。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/api/billing/settle/route.ts | 6 +++--- tests/app/api/freee-routes-integration.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/api/billing/settle/route.ts b/app/api/billing/settle/route.ts index b2dc7329..6f122a5b 100644 --- a/app/api/billing/settle/route.ts +++ b/app/api/billing/settle/route.ts @@ -120,9 +120,9 @@ export async function POST(req: Request): Promise { { status: 400 }, ); } - // JSON の `null` / 数値 / 文字列も parse は成功する。object でなければ欄を読む前に 400 で返す - // (null のまま欄を読むと TypeError → 500 + Sentry event になっていた・第 6 回 B-R6f)。 - if (capped.value === null || typeof capped.value !== 'object') { + // 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 は追加のみ)。 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();