From 2dc123aab9462ce42c7215870b94523e37233c5f Mon Sep 17 00:00:00 2001 From: Nexory Date: Wed, 10 Jun 2026 23:05:40 +0200 Subject: [PATCH] fix(predicate-gate): require validBefore in X-Payment authorization The X-Payment verification path treated `validBefore` as optional: it was absent from the required-field check, the expiry comparison was guarded by `if (auth.validBefore !== undefined)`, and signature recovery fell back to `BigInt(auth.validBefore ?? "0")`. An authorization that omits the field therefore skips the expiry check and is accepted as a non-expiring proof, whereas the sibling EIP-3009 path (verifyEip3009Auth) requires it. Add `validBefore` to the required-field check so the two auth paths agree and an authorization with no expiry bound is rejected with the existing 401. Adds a regression test that a missing-validBefore X-Payment is rejected. --- src/__tests__/predicate-gate.test.ts | 48 ++++++++++++++++++++++++++++ src/lib/middleware/predicate-gate.ts | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/__tests__/predicate-gate.test.ts b/src/__tests__/predicate-gate.test.ts index a96b385..817e284 100644 --- a/src/__tests__/predicate-gate.test.ts +++ b/src/__tests__/predicate-gate.test.ts @@ -683,6 +683,54 @@ describe("predicateGate", () => { }) }) + it("X-Payment: rejects an authorization that omits validBefore", async () => { + const { predicateGate } = await import( + "../lib/middleware/predicate-gate.js" + ) + const operator = + "0x5ECA0441311643608a8c9Ab8B250f695Dd32E2a8" as `0x${string}` + const gate = predicateGate({ + toolId: TEST_TOOL_ID, + operatorAddress: operator, + }) + const ctx: Partial = { gates: {} } + + // Authorization with NO validBefore. The signer (recovery mock -> TEST_CALLER) + // matches `from`, so the expiry check is the only bound on this token. When + // validBefore is absent that check is skipped, so an unbounded (non-expiring) + // proof is accepted. The sibling EIP-3009 path (verifyEip3009Auth) requires + // validBefore, so the two auth paths must agree: reject when it is missing. + const header = Buffer.from( + JSON.stringify({ + x402Version: 1, + scheme: "exact", + network: "base", + payload: { + signature: "0xabcd", + authorization: { + from: TEST_CALLER, + to: operator, + value: "0", + validAfter: "0", + // validBefore intentionally omitted + nonce: + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, + }, + }), + ).toString("base64") + const request = new Request("https://example.com/api", { + method: "POST", + headers: { "X-Payment": header }, + }) + + const response = await gate.check(request, ctx) + + expect(response?.status).toBe(401) + const body = await response?.json() + expect(body.error).toMatch(/missing required authorization fields/i) + }) + it("X-Payment: takes precedence over Authorization header", async () => { const { predicateGate } = await import( "../lib/middleware/predicate-gate.js" diff --git a/src/lib/middleware/predicate-gate.ts b/src/lib/middleware/predicate-gate.ts index 127e947..194b686 100644 --- a/src/lib/middleware/predicate-gate.ts +++ b/src/lib/middleware/predicate-gate.ts @@ -522,7 +522,7 @@ async function verifyXPaymentAuth( const auth = paymentPayload.payload?.authorization const signature = paymentPayload.payload?.signature - if (!auth?.from || !auth?.to || !signature || !auth?.nonce) { + if (!auth?.from || !auth?.to || !signature || !auth?.nonce || !auth?.validBefore) { return { error: "Predicate gate: X-Payment missing required authorization fields",