From ee5f6927bb97ba710038e31df615f1f07257d201 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Mon, 27 Jul 2026 22:05:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(x402):=20=E6=9C=89=E6=96=99=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=83=9D=E3=82=A4=E3=83=B3=E3=83=88=E3=81=AE?= =?UTF-8?q?=E5=BF=9C=E7=AD=94=E3=82=92=20no-store=20=E3=81=AB=E3=81=97?= =?UTF-8?q?=E5=85=B1=E6=9C=89=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5?= =?UTF-8?q?=E3=81=B8=E8=BC=89=E3=81=9B=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番実測 (2026-07-27) で `/api/paid/*` の応答が Next.js 既定の `cache-control: public, max-age=0, must-revalidate` を返しており、**共有キャッシュ (CDN / プロキシ) への保存が許可**されていた。402 は支払い条件 (payTo / 金額 / forwarder / commitVersion) を、200 は購入者だけが受け取るべき有料コンテンツを含む。 `Vary` に X-PAYMENT が無いため、URL だけを鍵にしたキャッシュが別の買い手へ再配信し得る。 ## 変更 - `handleFirstPartyPaidGet` の出口で `Cache-Control: no-store` を一律に付与。 分岐が 20 以上あり個々の return に付けると新規分岐で漏れるため、境界 1 箇所で保証する (掟13: 何の波及を断つ防御かをコメントで明記)。 - 402 と 200 の双方で no-store を固定するテストを追加。 契機は x402 上流の PR (402 応答へ Cache-Control: no-store を足す議論) と、 主要 Facilitator 15 件の安全規則違反を報告した査読前研究 (arXiv 2026-07-21)。 研究が挙げる他 5 系統 (settle 前の状態再検証 / ガス消耗 / settle 失敗の成功扱い / 支払い後の未配信 / 不明状態の成功扱い) は PR #273 で対応済みのため本 PR では触れない。 ## 検証 typecheck / **full vitest 8036 passed (392 files)** / next build + bundle 予算 全ルート内 / eslint / audit-gate / lockfile-gate PASS。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015CNMEwurCJNTrzvr2qypSd --- app/api/paid/_shared.ts | 28 ++++++++++++++++++++++++++ tests/app/api/paid-first-party.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/api/paid/_shared.ts b/app/api/paid/_shared.ts index eecd4528..5742068d 100644 --- a/app/api/paid/_shared.ts +++ b/app/api/paid/_shared.ts @@ -223,10 +223,38 @@ function paymentChallenge( } } +/** + * x402 の有料エンドポイントは共有キャッシュに載せない。 + * + * 402 チャレンジは支払い条件 (payTo / 金額 / forwarder / commitVersion) を、200 は購入者だけが + * 受け取るべき有料コンテンツを含む。Next.js 既定の `public, max-age=0, must-revalidate` は + * **共有キャッシュ (CDN / プロキシ) への保存を許可**してしまう (本番実測 2026-07-27)。 + * + * 何の波及を断つ防御か (掟13): ①条件変更後も古い支払い条件が配布され、買い手が旧 payTo / + * 旧価格で払う ②URL だけを鍵にしたキャッシュが、未払いリクエストへ有料コンテンツを返す。 + * `Vary` に X-PAYMENT が無い以上、no-store で塞ぐのが確実。 + */ +function noStore(res: NextResponse): NextResponse { + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +/** + * ハンドラの出口で一律 no-store を被せる。分岐が 20 以上あり、個々の return に付けると + * 新しい分岐を足したときに漏れるため、境界 1 箇所で保証する。 + */ export async function handleFirstPartyPaidGet( req: Request, resource: FirstPartyResource, content: PaidContent, +): Promise { + return noStore(await handleFirstPartyPaidGetInner(req, resource, content)); +} + +async function handleFirstPartyPaidGetInner( + req: Request, + resource: FirstPartyResource, + content: PaidContent, ): Promise { if (!env.enableX402Facilitator) { return NextResponse.json({ error: 'not_found' }, { status: 404 }); diff --git a/tests/app/api/paid-first-party.test.ts b/tests/app/api/paid-first-party.test.ts index cc30e2b5..a655d1ee 100644 --- a/tests/app/api/paid-first-party.test.ts +++ b/tests/app/api/paid-first-party.test.ts @@ -188,6 +188,31 @@ describe('first-party paid x402 routes', () => { expect(routeMocks.settle).not.toHaveBeenCalled(); }); + // 402 は支払い条件 (payTo/金額/forwarder)、200 は購入者だけが受け取る有料コンテンツを含む。 + // Next.js 既定は `public, max-age=0, must-revalidate` で共有キャッシュへの保存を許可して + // しまうため、ハンドラ出口で no-store を被せている。分岐追加時の付け忘れをここで固定する。 + it('402 / 200 とも Cache-Control: no-store (共有キャッシュへ載せない)', async () => { + const { demo } = await load(); + const challenge = await demo.GET(req('/api/paid/demo')); + expect(challenge.status).toBe(402); + expect(challenge.headers.get('cache-control')).toBe('no-store'); + + routeMocks.verify.mockResolvedValue( + NextResponse.json({ isValid: true, payer: PAYER }), + ); + routeMocks.settle.mockImplementation(async () => + NextResponse.json({ + success: true, + transaction: TX_HASH, + network: 'eip155:80002', + payer: PAYER, + }), + ); + const paid = await demo.GET(req('/api/paid/demo', paymentHeader())); + expect(paid.status).toBe(200); + expect(paid.headers.get('cache-control')).toBe('no-store'); + }); + it('不正 X-PAYMENT → 402', async () => { const { demo } = await load(); const res = await demo.GET(req('/api/paid/demo', 'not-json'));