Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions typescript/.changeset/decoded-path-route-match.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 45 additions & 10 deletions typescript/packages/core/src/http/x402HTTPResourceServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -1045,7 +1048,11 @@ export class x402HTTPResourceServer {
): Promise<HTTPResponseInstructions> {
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
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
*
Expand Down
108 changes: 108 additions & 0 deletions typescript/packages/core/test/unit/http/routeMatching.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
52 changes: 52 additions & 0 deletions typescript/packages/http/express/src/encodedPathBypass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(resolve => server.once("listening", () => resolve()));
port = (server.address() as AddressInfo).port;
});

afterAll(async () => {
await new Promise<void>(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);
});
});
22 changes: 21 additions & 1 deletion typescript/packages/http/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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"),
};
Expand Down
19 changes: 19 additions & 0 deletions typescript/packages/http/fastify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) ||
Expand Down
Loading
Loading