diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py index da85989c2..28c055d28 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py @@ -663,11 +663,14 @@ def build_url_with_params( def is_service_registered(url: str, registered_services: set[str]) -> bool: """Check if a URL is registered for x402 requests. - Matches by origin (protocol + hostname + port) or prefix. + Matches by origin (protocol + hostname + port) or by same-origin path prefix. + + Does **not** use raw ``url.startswith(registered)`` — that allows hostname-suffix + and other prefix bypasses (e.g. ``https://good.com.evil.com`` vs ``https://good.com``). Args: url: The URL to check - registered_services: Set of registered service URLs + registered_services: Set of registered service URLs or origins Returns: True if the service is registered, False otherwise @@ -678,12 +681,34 @@ def is_service_registered(url: str, registered_services: set[str]) -> bool: try: parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + return False origin = f"{parsed.scheme}://{parsed.netloc}" for registered in registered_services: - # Check if origin matches or URL starts with registered prefix - if origin == registered or url.startswith(registered): + # Bare origin registration: "https://api.example.com" + if origin == registered: return True + + reg = urlparse(registered) + if not reg.scheme or not reg.netloc: + continue + + reg_origin = f"{reg.scheme}://{reg.netloc}" + if origin != reg_origin: + continue + + # Origin-only URL form ("https://api.example.com/") → any path on that origin + reg_path = reg.path or "/" + if reg_path == "/" and not reg.query and not reg.fragment: + return True + + # Same-origin path match or segment-boundary prefix + req_path = parsed.path or "/" + reg_prefix = reg_path if reg_path.endswith("/") else f"{reg_path}/" + if req_path == reg_path or req_path.startswith(reg_prefix): + return True + return False except Exception: return False diff --git a/typescript/agentkit/src/action-providers/x402/utils.isServiceRegistered.test.ts b/typescript/agentkit/src/action-providers/x402/utils.isServiceRegistered.test.ts new file mode 100644 index 000000000..e4a725601 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402/utils.isServiceRegistered.test.ts @@ -0,0 +1,30 @@ +import { isServiceRegistered } from "./utils"; + +describe("isServiceRegistered", () => { + const registered = new Set(["https://api.example.com", "https://pay.example.com/v1"]); + + it("allows exact origin registration for any path on that origin", () => { + expect(isServiceRegistered("https://api.example.com/foo", registered)).toBe(true); + }); + + it("allows same-origin path prefix registrations", () => { + expect(isServiceRegistered("https://pay.example.com/v1/charge", registered)).toBe(true); + expect(isServiceRegistered("https://pay.example.com/v1", registered)).toBe(true); + }); + + it("rejects hostname-suffix bypass of prefix matching", () => { + expect(isServiceRegistered("https://api.example.com.evil.com/x", registered)).toBe(false); + }); + + it("rejects different hosts even when the string shares a prefix", () => { + expect(isServiceRegistered("https://api.example.com.attacker/x", registered)).toBe(false); + expect(isServiceRegistered("https://evil.com/https://api.example.com", registered)).toBe( + false, + ); + }); + + it("rejects sibling paths outside the registered prefix", () => { + expect(isServiceRegistered("https://pay.example.com/v2/charge", registered)).toBe(false); + expect(isServiceRegistered("https://pay.example.com/v10", registered)).toBe(false); + }); +}); diff --git a/typescript/agentkit/src/action-providers/x402/utils.ts b/typescript/agentkit/src/action-providers/x402/utils.ts index 356316687..2feedae73 100644 --- a/typescript/agentkit/src/action-providers/x402/utils.ts +++ b/typescript/agentkit/src/action-providers/x402/utils.ts @@ -615,10 +615,13 @@ export function buildUrlWithParams( /** * Checks if a URL is registered for x402 requests. - * Matches by origin (protocol + hostname + port) or prefix. + * Matches by origin (protocol + hostname + port) or by same-origin path prefix. + * + * Does **not** use raw `url.startsWith(registered)` — that allows hostname-suffix + * and other prefix bypasses (e.g. `https://good.com.evil.com` vs `https://good.com`). * * @param url - The URL to check - * @param registeredServices - Set of registered service URLs + * @param registeredServices - Set of registered service URLs or origins * @returns True if the service is registered, false otherwise */ export function isServiceRegistered(url: string, registeredServices: Set): boolean { @@ -628,11 +631,32 @@ export function isServiceRegistered(url: string, registeredServices: Set try { const parsed = new URL(url); - const origin = parsed.origin; for (const registered of registeredServices) { - // Check if origin matches or URL starts with registered prefix - if (origin === registered || url.startsWith(registered)) { + // Bare origin registration: "https://api.example.com" + if (parsed.origin === registered) { + return true; + } + + let reg: URL; + try { + reg = new URL(registered); + } catch { + continue; + } + + if (parsed.origin !== reg.origin) { + continue; + } + + // Origin-only URL form ("https://api.example.com/") → any path on that origin + if (reg.pathname === "/" && reg.search === "" && reg.hash === "") { + return true; + } + + // Same-origin path match or segment-boundary prefix + const regPrefix = reg.pathname.endsWith("/") ? reg.pathname : `${reg.pathname}/`; + if (parsed.pathname === reg.pathname || parsed.pathname.startsWith(regPrefix)) { return true; } }