diff --git a/typescript/.changeset/decoded-path-route-match.md b/typescript/.changeset/decoded-path-route-match.md new file mode 100644 index 0000000000..c11c4576c3 --- /dev/null +++ b/typescript/.changeset/decoded-path-route-match.md @@ -0,0 +1,9 @@ +--- +"@x402/core": patch +"@x402/express": patch +"@x402/hono": patch +"@x402/fastify": patch +"@x402/next": patch +--- + +HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. diff --git a/typescript/packages/core/src/http/x402HTTPResourceServer.ts b/typescript/packages/core/src/http/x402HTTPResourceServer.ts index b07afc202f..539047f26b 100644 --- a/typescript/packages/core/src/http/x402HTTPResourceServer.ts +++ b/typescript/packages/core/src/http/x402HTTPResourceServer.ts @@ -273,6 +273,9 @@ export interface HTTPRequestContext { method: string; paymentHeader?: string; routePattern?: string; + // The framework's own decoded routing view of the path (e.g. Express + // `req.path` after URI decoding, or Hono `c.req.path`), if distinct from `path`. + decodedPath?: string; } /** @@ -533,7 +536,7 @@ export class x402HTTPResourceServer { const { adapter, path } = context; // Find matching route - const routeMatch = this.getRouteConfig(path, method); + const routeMatch = this.getRouteConfig(path, method, context.decodedPath); if (!routeMatch) { return { type: "no-payment-required" }; // No payment required for this route } @@ -913,7 +916,7 @@ export class x402HTTPResourceServer { */ requiresPayment(context: HTTPRequestContext): boolean { const method = context.method || context.adapter.getMethod(); - return this.getRouteConfig(context.path, method) !== undefined; + return this.getRouteConfig(context.path, method, context.decodedPath) !== undefined; } /** @@ -1045,7 +1048,11 @@ export class x402HTTPResourceServer { ): Promise { const settlementHeaders = failure.headers; const routeConfig = transportContext - ? this.getRouteConfig(transportContext.request.path, transportContext.request.method) + ? this.getRouteConfig( + transportContext.request.path, + transportContext.request.method, + transportContext.request.decodedPath, + ) : undefined; const customBody = routeConfig?.config.settlementFailedResponseBody @@ -1228,24 +1235,39 @@ export class x402HTTPResourceServer { /** * Get route configuration for a request * + * Checks the escaped `path` first, then the framework's `decodedPath` + * (if distinct), so a route can't be bypassed via either representation. + * * @param path - Request path * @param method - HTTP method + * @param decodedPath - Framework decoded routing view of the path, if distinct from path * @returns Route configuration and pattern, or undefined if no match */ private getRouteConfig( path: string, method: string, + decodedPath?: string, ): { config: RouteConfig; pattern: string } | undefined { - const normalizedPath = this.normalizePath(path); const upperMethod = method.toUpperCase(); - const matchingRoute = this.compiledRoutes.find( - route => - route.regex.test(normalizedPath) && (route.verb === "*" || route.verb === upperMethod), - ); + const findMatch = (candidate: string): { config: RouteConfig; pattern: string } | undefined => { + const matchingRoute = this.compiledRoutes.find( + route => route.regex.test(candidate) && (route.verb === "*" || route.verb === upperMethod), + ); + if (!matchingRoute) return undefined; + return { config: matchingRoute.config, pattern: matchingRoute.pattern }; + }; + + const match = findMatch(this.normalizePath(path)); + if (match !== undefined) { + return match; + } + + if (decodedPath !== undefined && decodedPath !== path) { + return findMatch(this.normalizeDecodedPath(decodedPath)); + } - if (!matchingRoute) return undefined; - return { config: matchingRoute.config, pattern: matchingRoute.pattern }; + return undefined; } /** @@ -1416,6 +1438,19 @@ export class x402HTTPResourceServer { return normalized.replace(/\/+/g, "/").replace(/(.+?)\/+$/, "$1"); } + /** + * Normalize an already framework-decoded path. Does not decode + * percent-escapes, unlike `normalizePath`, since this input was + * already decoded once by the router. + * + * @param path - Framework-decoded path + * @returns Normalized path + */ + private normalizeDecodedPath(path: string): string { + const pathWithoutQuery = path.split(/[?#]/)[0]; + return pathWithoutQuery.replace(/\/+/g, "/").replace(/(.+?)\/+$/, "$1") || "/"; + } + /** * Generate paywall HTML for browser requests * diff --git a/typescript/packages/core/test/unit/http/routeMatching.test.ts b/typescript/packages/core/test/unit/http/routeMatching.test.ts new file mode 100644 index 0000000000..90c9341a7e --- /dev/null +++ b/typescript/packages/core/test/unit/http/routeMatching.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { + x402HTTPResourceServer, + HTTPRequestContext, + HTTPAdapter, +} from "../../../src/http/x402HTTPResourceServer"; +import { x402ResourceServer } from "../../../src/server/x402ResourceServer"; + +/** + * Minimal adapter for route-matching tests. The adapter is only consulted when + * `method` is empty, so a stub is sufficient. + */ +class StubAdapter implements HTTPAdapter { + getHeader(): string | undefined { + return undefined; + } + getMethod(): string { + return "GET"; + } + getPath(): string { + return "/"; + } + getUrl(): string { + return "http://localhost/"; + } + getAcceptHeader(): string { + return ""; + } + getUserAgent(): string { + return ""; + } +} + +type DecodedPathNormalizer = { + normalizeDecodedPath: (path: string) => string; +}; + +/** + * Build a request context that carries an explicit path and method. + * + * @param path - Escaped request path + * @param method - HTTP method + * @param decodedPath - Framework decoded routing view, if any + * @returns HTTP request context for route-matching tests + */ +function context(path: string, method: string = "GET", decodedPath?: string): HTTPRequestContext { + return { + adapter: new StubAdapter(), + path, + method, + decodedPath, + }; +} + +describe("normalizeDecodedPath", () => { + const server = new x402HTTPResourceServer({} as x402ResourceServer, { + "GET /api/premium": { accepts: [] }, + }); + const normalizeDecodedPath = (path: string): string => + (server as unknown as DecodedPathNormalizer).normalizeDecodedPath(path); + + it.each([ + ["/api", "/api"], + ["/api/", "/api"], + ["/api//users", "/api/users"], + ["/api?query=1", "/api"], + ["/api#fragment", "/api"], + ["", "/"], + // Already-decoded input is passed through, not re-decoded. + ["/api/x%41", "/api/x%41"], + ["/api/premium", "/api/premium"], + ])("normalizes %s to %s", (inputPath, expected) => { + expect(normalizeDecodedPath(inputPath)).toBe(expected); + }); +}); + +describe("decoded path divergence bypass", () => { + const server = (pattern: string = "GET /api/premium"): x402HTTPResourceServer => + new x402HTTPResourceServer({} as x402ResourceServer, { + [pattern]: { accepts: [] }, + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "literal route requires payment when decoded path matches %s", + escapedPath => { + expect(server().requiresPayment(context(escapedPath, "GET", "/api/premium"))).toBe(true); + }, + ); + + it("literal route misses without decoded path", () => { + // Pre-fix behavior: only the escaped path is checked. + expect(server().requiresPayment(context("/api%2Fpremium", "GET", undefined))).toBe(false); + }); + + it("real extra segment still not matched", () => { + // Guard against over-matching a genuinely different resource. + const httpServer = server("GET /api/users/:id"); + expect(httpServer.requiresPayment(context("/api/users/x/y", "GET", "/api/users/x/y"))).toBe( + false, + ); + }); + + it("unrelated decoded path does not require payment", () => { + expect(server().requiresPayment(context("/public/report", "GET", "/public/report"))).toBe( + false, + ); + }); +}); diff --git a/typescript/packages/http/express/src/encodedPathBypass.test.ts b/typescript/packages/http/express/src/encodedPathBypass.test.ts index 6c770335a7..3cad2bdf4d 100644 --- a/typescript/packages/http/express/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/express/src/encodedPathBypass.test.ts @@ -179,3 +179,55 @@ describe("express end-to-end: trailing wildcard route prefix", () => { expect(await statusFor(port, "/health")).toBe(200); }); }); + +describe("express end-to-end: literal route percent-encoded separator", () => { + let server: Server; + let port: number; + + beforeAll(async () => { + const app = express(); + const resourceServer = await buildTestResourceServer(); + app.use( + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: TEST_NETWORK, + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", (_req, res) => res.status(200).send("paid content")); + + server = app.listen(0); + await new Promise(resolve => server.once("listening", () => resolve())); + port = (server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); + }); + + it("returns 402 for the baseline literal route", async () => { + expect(await statusFor(port, "/api/premium")).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + expect(await statusFor(port, path)).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + expect(await statusFor(port, "/health")).toBe(404); + }); +}); diff --git a/typescript/packages/http/express/src/index.ts b/typescript/packages/http/express/src/index.ts index d997a0307f..2feddf35c7 100644 --- a/typescript/packages/http/express/src/index.ts +++ b/typescript/packages/http/express/src/index.ts @@ -65,6 +65,22 @@ function sendInternalError(res: Response, error: unknown): void { res.status(500).json({ error: "Internal Server Error" }); } +/** + * Decode a request path into the view Express's router uses for literal + * segments (`%2F` becomes `/`). Malformed escapes keep the original path + * so matching can still consult the escaped representation. + * + * @param path - Request path, possibly still percent-encoded + * @returns Decoded path, or the original path if decoding fails + */ +function decodedRoutePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Express payment middleware for x402 protocol (direct HTTP server instance). * @@ -153,9 +169,13 @@ export function paymentMiddlewareFromHTTPServer( return async (req: Request, res: Response, next: NextFunction) => { // Create adapter and context const adapter = new ExpressAdapter(req); + // Express matches wildcard/param routes on the escaped path but literal + // routes on the decoded path, so match both. + const path = req.path; const context: HTTPRequestContext = { adapter, - path: req.path, + path, + decodedPath: decodedRoutePath(path), method: req.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), }; diff --git a/typescript/packages/http/fastify/src/index.ts b/typescript/packages/http/fastify/src/index.ts index ae3b004034..50b2027251 100644 --- a/typescript/packages/http/fastify/src/index.ts +++ b/typescript/packages/http/fastify/src/index.ts @@ -229,6 +229,22 @@ function sendInternalError(reply: FastifyReply, error: unknown): void { reply.status(500).send({ error: "Internal Server Error" }); } +/** + * Decode a request path into the view Fastify's router uses for literal + * segments (`%2F` becomes `/`). Malformed escapes keep the original path + * so matching can still consult the escaped representation. + * + * @param path - Request path, possibly still percent-encoded + * @returns Decoded path, or the original path if decoding fails + */ +function decodedRoutePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Configuration for registering a payment scheme with a specific network. */ @@ -333,9 +349,12 @@ export function paymentMiddlewareFromHTTPServer( app.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) => { const path = request.url.split("?")[0]; const adapter = new FastifyAdapter(request); + // Fastify matches wildcard/param routes on the escaped path but literal + // routes on the decoded path, so match both. const context: HTTPRequestContext = { adapter, path, + decodedPath: decodedRoutePath(path), method: request.method, paymentHeader: (request.headers["payment-signature"] as string | undefined) || diff --git a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts index 6d5892a49e..495f9faa91 100644 --- a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts @@ -130,3 +130,52 @@ describe("fastify end-to-end: percent-encoded line terminator under wildcard rou expect(await statusFor(port, "/health")).toBe(200); }); }); + +describe("fastify end-to-end: literal route percent-encoded separator", () => { + let app: FastifyInstance; + let port: number; + + beforeAll(async () => { + app = Fastify(); + const resourceServer = await buildTestResourceServer(); + paymentMiddleware( + app, + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: TEST_NETWORK, + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ); + app.get("/api/premium", async () => "paid content"); + await app.listen({ host: "127.0.0.1", port: 0 }); + port = (app.server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await app.close(); + }); + + it("returns 402 for the baseline literal route", async () => { + expect(await statusFor(port, "/api/premium")).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + expect(await statusFor(port, path)).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + expect(await statusFor(port, "/health")).toBe(404); + }); +}); diff --git a/typescript/packages/http/hono/src/encodedPathBypass.test.ts b/typescript/packages/http/hono/src/encodedPathBypass.test.ts index 132505a9de..ebe35f2570 100644 --- a/typescript/packages/http/hono/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/hono/src/encodedPathBypass.test.ts @@ -91,3 +91,142 @@ describe("hono end-to-end: encoded path separator in a :param segment", () => { expect(res.status).toBe(200); }); }); + +describe("hono end-to-end: literal route percent-encoded separator", () => { + /** + * A literal route must stay gated when a request encodes its path + * separator, even though Hono's getPath uses decodeURI (keeping `%2F`). + * + * @returns A Hono app with a paid `/api/premium` handler + */ + async function buildLiteralApp() { + const app = new Hono(); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:84532" }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: "eip155:84532" }), + }); + resourceServer.register("eip155:84532", { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + app.use( + "*", + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: "eip155:84532", + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", c => c.json({ secret: "paid content" })); + return app; + } + + it("returns 402 for the baseline literal route", async () => { + const res = await (await buildLiteralApp()).request("/api/premium"); + expect(res.status).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + const res = await (await buildLiteralApp()).request(path); + expect(res.status).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + const res = await (await buildLiteralApp()).request("/health"); + expect(res.status).toBe(404); + }); +}); + +describe("hono end-to-end: literal route basePath", () => { + /** + * A literal route must stay gated when the app is mounted under a basePath, + * which Hono strips before matching, like Starlette's root_path. + * + * @returns A Hono app mounted at `/svc` with a paid `/api/premium` handler + */ + async function buildMountedApp() { + const app = new Hono().basePath("/svc"); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:84532" }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: "eip155:84532" }), + }); + resourceServer.register("eip155:84532", { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + app.use( + "*", + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: "eip155:84532", + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", c => c.json({ secret: "paid content" })); + return app; + } + + it("returns 402 for the mounted literal route", async () => { + const res = await (await buildMountedApp()).request("/svc/api/premium"); + expect(res.status).toBe(402); + }); + + it.each([["/svc/api%2Fpremium"], ["/svc/api%2fpremium"]])( + "returns 402 for mounted percent-encoded separator %s", + async path => { + const res = await (await buildMountedApp()).request(path); + expect(res.status).toBe(402); + }, + ); +}); diff --git a/typescript/packages/http/hono/src/index.ts b/typescript/packages/http/hono/src/index.ts index 7c56f277d5..ff05a3eb76 100644 --- a/typescript/packages/http/hono/src/index.ts +++ b/typescript/packages/http/hono/src/index.ts @@ -16,6 +16,7 @@ import { } from "@x402/core/server"; import { SchemeNetworkServer, Network } from "@x402/core/types"; import { Context, MiddlewareHandler } from "hono"; +import { basePath } from "hono/route"; import { HonoAdapter } from "./adapter"; /** @@ -67,6 +68,39 @@ function internalErrorResponse(c: Context, error: unknown): Response { return c.json({ error: "Internal Server Error" }, 500); } +/** + * `c.req.path` with percent-escapes decoded and any Hono `basePath` mount + * prefix stripped, mirroring Starlette's get_route_path so mounted apps + * stay protected. + * + * @param c - Hono context + * @returns Decoded path relative to the app mount, or the original path if decoding fails + */ +function decodedRoutePath(c: Context): string { + let path: string; + try { + path = decodeURIComponent(c.req.path); + } catch { + path = c.req.path; + } + let rootPath = ""; + try { + rootPath = basePath(c); + } catch { + return path; + } + if (!rootPath || rootPath === "/" || !path.startsWith(rootPath)) { + return path; + } + if (path === rootPath) { + return ""; + } + if (path[rootPath.length] === "/") { + return path.slice(rootPath.length); + } + return path; +} + /** * Hono payment middleware for x402 protocol (direct HTTP server instance). * @@ -155,9 +189,13 @@ export function paymentMiddlewareFromHTTPServer( return async (c: Context, next: () => Promise) => { // Create adapter and context const adapter = new HonoAdapter(c); + // Hono matches wildcard/param routes on the escaped path but literal + // routes on the decoded path, so match both. + const path = c.req.path; const context: HTTPRequestContext = { adapter, - path: c.req.path, + path, + decodedPath: decodedRoutePath(c), method: c.req.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), }; diff --git a/typescript/packages/http/next/src/utils.test.ts b/typescript/packages/http/next/src/utils.test.ts index d3fc988a60..54ae3a68df 100644 --- a/typescript/packages/http/next/src/utils.test.ts +++ b/typescript/packages/http/next/src/utils.test.ts @@ -210,6 +210,14 @@ describe("createRequestContext", () => { expect(context.adapter).toBeDefined(); }); + it("sets decodedPath when the request path encodes a separator", () => { + const req = createMockRequest({ url: "https://example.com/api%2Fpremium" }); + + const context = createRequestContext(req); + + expect(context.decodedPath).toBe("/api/premium"); + }); + it("extracts x-payment header", () => { const req = createMockRequest({ headers: { "X-Payment": "payment-data" } }); diff --git a/typescript/packages/http/next/src/utils.ts b/typescript/packages/http/next/src/utils.ts index 2b2f2d7aa3..d56a53d309 100644 --- a/typescript/packages/http/next/src/utils.ts +++ b/typescript/packages/http/next/src/utils.ts @@ -54,6 +54,34 @@ export function createInternalErrorResponse(error: unknown): NextResponse { }); } +/** + * Decode a request path into the view Next.js uses for literal segments + * (`%2F` becomes `/`), with any `basePath` prefix stripped so mounted apps + * stay protected. + * + * @param request - The Next.js request object + * @returns Decoded path, or the original pathname if decoding fails + */ +function decodedRoutePath(request: NextRequest): string { + let path = request.nextUrl.pathname; + try { + path = decodeURIComponent(path); + } catch { + path = request.nextUrl.pathname; + } + const rootPath = request.nextUrl.basePath; + if (!rootPath || !path.startsWith(rootPath)) { + return path; + } + if (path === rootPath) { + return ""; + } + if (path[rootPath.length] === "/") { + return path.slice(rootPath.length); + } + return path; +} + /** * Prepares an existing x402HTTPResourceServer with initialization logic * @@ -137,9 +165,13 @@ export function createHttpServer( export function createRequestContext(request: NextRequest): HTTPRequestContext { // Create adapter and context const adapter = new NextAdapter(request); + // Next matches wildcard/param routes on the escaped path but literal + // routes on the decoded path, so match both. + const path = request.nextUrl.pathname; return { adapter, - path: request.nextUrl.pathname, + path, + decodedPath: decodedRoutePath(request), method: request.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), };