From 8cfcf11a88f9806f7ec1fbd8d989cf6677e394e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Tue, 25 Aug 2026 14:51:51 +0200 Subject: [PATCH] Decode percent-encoded identifiers in URLs Cloudflare's single-page-application fallback percent-encodes the path on a fresh load or reload, so `rad:z4V1...` arrives as `rad%3Az4V1...` and leaks into links and the seed command. Only identifiers are decoded: revision and path segments stay encoded, because branch and tag lookups are keyed on the encoded name. The seed command needs a second fix: the repo path segment is an alias when one was used, and `rad seed ` is not valid, the `rad seed` command only supports RIDs and not aliases. Co-Authored-By: Guillaume --- src/lib/router.ts | 26 +++++++- src/views/repos/Header.svelte | 2 +- tests/unit/router.test.ts | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/lib/router.ts b/src/lib/router.ts index 4053b23bd..bf3bb1816 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -418,6 +418,23 @@ export function extractBaseUrl(hostAndPort: string): BaseUrl { }; } +// Cloudflare's static-asset handler percent-encodes the path when it serves +// the single-page-application fallback, so a fresh load or reload arrives with +// `rad%3Az4V1...` in place of `rad:z4V1...`. Identifier segments are decoded +// back to their canonical form here, at the point the route is parsed, so the +// encoded shape never reaches link building or anything user-visible. Only +// identifiers are decoded: revision and path segments are deliberately kept +// encoded, because branch and tag lookups are keyed on the encoded name. +// Decoding throws on a stray `%` in a repo alias, which is operator-supplied +// and unvalidated, so an undecodable segment is passed through untouched. +function decodeIdentifier(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + function urlToRoute(url: URL): Route | null { // Normalize a trailing slash (`/guides/protocol/` → `/guides/protocol`) so // both the strict marketing resolver and the lenient repo resolver agree. @@ -437,11 +454,16 @@ function urlToRoute(url: URL): Route | null { if (id === "users") { const did = segments.shift(); if (did) { - return { resource: "users", baseUrl, did }; + return { resource: "users", baseUrl, did: decodeIdentifier(did) }; } return null; } else if (id) { - return resolveRepoRoute(baseUrl, id, segments, url.search); + return resolveRepoRoute( + baseUrl, + decodeIdentifier(id), + segments, + url.search, + ); } else { return { resource: "nodes", diff --git a/src/views/repos/Header.svelte b/src/views/repos/Header.svelte index f935fec0d..22f4ee185 100644 --- a/src/views/repos/Header.svelte +++ b/src/views/repos/Header.svelte @@ -163,6 +163,6 @@ {/if} - + diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts index b08e3d7ff..64d55fdcb 100644 --- a/tests/unit/router.test.ts +++ b/tests/unit/router.test.ts @@ -361,3 +361,120 @@ describe("extractBaseUrl", () => { }); }); }); + +// Cloudflare's single-page-application fallback percent-encodes the path on a +// fresh load or reload, so these paths are what the router actually receives +// in production for a URL the user sees and copies in its decoded form. +describe("percent-encoded path segments", () => { + const node = { + hostname: "example.node.tld", + scheme: "http", + port: config.nodes.defaultHttpdPort, + }; + + test("encoded repo id is decoded", () => { + expectPathToRoute( + "/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/patches/68579c8c8c792d39522c31544ed696b6e3a607fb", + { + resource: "repo.patch", + node, + repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + patch: "68579c8c8c792d39522c31544ed696b6e3a607fb", + }, + ); + }); + + test("decoded repo id is left as is", () => { + expectPathToRoute( + "/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + { + resource: "repo.source", + node, + repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + route: "", + }, + ); + }); + + test("encoded did is decoded", () => { + expectPathToRoute( + "/nodes/example.node.tld/users/did%3Akey%3Az6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd", + { + resource: "users", + baseUrl: node, + did: "did:key:z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd", + }, + ); + }); + + // Repo aliases are operator-supplied and unvalidated, so a segment that is + // not a well-formed encoding must not throw out of route parsing. + test("undecodable repo id is passed through", () => { + expectPathToRoute("/nodes/example.node.tld/100%", { + resource: "repo.source", + node, + repo: "100%", + route: "", + }); + }); + + // Revision segments must stay encoded: branch and tag lookups are keyed on + // the encoded name, and decoding `%2F` here would turn a tag like + // `v1.0/release` into two path segments. + test("encoded tag name in a tree route stays encoded", () => { + expectPathToRoute( + "/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease", + { + resource: "repo.source", + node, + repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + route: "v1.0%2Frelease", + }, + ); + }); + + test("encoded tag name in a history route stays encoded", () => { + expectPathToRoute( + "/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/history/v1.0%2Frelease", + { + resource: "repo.history", + node, + repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + revision: "v1.0%2Frelease", + }, + ); + }); + + test("encoded tag name under an explicit peer stays encoded", () => { + expectPathToRoute( + "/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/remotes/z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd/tree/v1.0%2Frelease", + { + resource: "repo.source", + node, + repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", + peer: "z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd", + route: "v1.0%2Frelease", + }, + ); + }); + + // Decoding the repo id changes what `routeToPath` emits, which is what heals + // the address bar and the "Copy link" button on subsequent navigation. + test("an encoded path round-trips to its decoded form", () => { + const route = testExports.urlToRoute( + new URL( + "/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease", + "http://localhost/", + ), + ); + expect(testExports.routeToPath(route!)).toEqual( + "/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease", + ); + }); + + function expectPathToRoute(relativeUrl: string, route: Route | null) { + expect( + testExports.urlToRoute(new URL(relativeUrl, "http://localhost/")), + ).toEqual(route); + } +});