Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/skip-non-http-anchor-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Anchor click handling no longer intercepts links with a non-http(s) scheme (`blob:`, `mailto:`, `tel:`, `data:`, …). Previously `blob:` links could be wrongly routed because they inherit the page origin and bypassed the same-origin check.
3 changes: 3 additions & 0 deletions src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export function setupNativeEvents({
if (a.hasAttribute("download") || (rel && rel.includes("external"))) return;

const url = svg ? new URL(href, document.baseURI) : new URL(href);
// Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
// inherit the page origin, so the origin check below won't reject them. #382
if (url.protocol !== "https:" && url.protocol !== "http:") return;
if (
url.origin !== window.location.origin ||
(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
Expand Down
34 changes: 33 additions & 1 deletion test/data/events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,23 +184,35 @@ describe("anchor link handling", () => {
} as any;

global.URL = class MockURL {
protocol: string;
origin: string;
pathname: string;
search: string;
hash: string;

constructor(url: string) {
if (url.startsWith("/")) {
if (url.startsWith("blob:")) {
// blob: URLs inherit the page origin, so the origin check alone
// would let them through — they must be rejected by protocol. #382
this.protocol = "blob:";
this.origin = "https://example.com";
this.pathname = url;
this.search = "";
this.hash = "";
} else if (url.startsWith("/")) {
this.protocol = "https:";
this.origin = "https://example.com";
this.pathname = url;
this.search = "";
this.hash = "";
} else if (url.startsWith("https://example.com")) {
this.protocol = "https:";
this.origin = "https://example.com";
this.pathname = url.replace("https://example.com", "") || "/";
this.search = "";
this.hash = "";
} else {
this.protocol = "https:";
this.origin = "https://other.com";
this.pathname = "/";
this.search = "";
Expand Down Expand Up @@ -249,6 +261,26 @@ describe("anchor link handling", () => {
});
});

test("should ignore non-http(s) links like blob: (#382)", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
// With no base path, a blob: link shares the page origin and its pathname
// isn't caught by the path-prefix check — so it must be rejected by
// protocol, else the router routes a garbage pathname (#382).
mockRouter.base = { path: () => "" } as any;
setupNativeEvents()(mockRouter);

const link = createMockElement("a", { href: "blob:https://example.com/abc" });
const event = createMockEvent("click", link, { path: [link] });

clickHandler(event);

expect(event.preventDefault).not.toHaveBeenCalled();
expect(navigateFromRoute).not.toHaveBeenCalled();
});
});

test("should ignore clicks with modifier keys", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
Expand Down