Skip to content
Merged
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
25 changes: 21 additions & 4 deletions assets/lib/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export type RedirectMode = "href" | "patch" | "navigate";

const REDIRECT_MODES: readonly RedirectMode[] = ["href", "patch", "navigate"];

const SCHEME_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;

export function isAllowedRedirectDestination(destination: string): boolean {
const trimmed = destination.trim();
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;

const schemeMatch = SCHEME_PREFIX.exec(trimmed);
if (schemeMatch) {
const scheme = schemeMatch[0].slice(0, -1).toLowerCase();
return scheme === "http" || scheme === "https";
}

return true;
}

export interface RedirectInput {
destination: string;
newTab?: boolean;
Expand Down Expand Up @@ -38,7 +54,7 @@ export function readDomItemRedirect(
fallback?: string
): RedirectInput | null {
if (!itemEl) {
if (!fallback) return null;
if (!fallback || !isAllowedRedirectDestination(fallback)) return null;
return { destination: fallback };
}

Expand All @@ -47,7 +63,7 @@ export function readDomItemRedirect(

const destination =
itemEl.getAttribute("data-to") || fallback || itemEl.getAttribute("data-value") || "";
if (!destination) return null;
if (!destination || !isAllowedRedirectDestination(destination)) return null;

const mode = REDIRECT_MODES.includes(dataRedirect as RedirectMode)
? (dataRedirect as RedirectMode)
Expand All @@ -61,7 +77,7 @@ export function readDomItemRedirect(
* Execute a redirect described by `input`.
*
* Behavior:
* - No-op (returns false) when `input` is null or has empty destination.
* - No-op (returns false) when `input` is null, has empty destination, or destination uses a disallowed URL scheme.
* - `newTab === true` -> always `window.open(_, "_blank", noopener,noreferrer)`.
* - LV not connected -> `window.location.href = destination` regardless of mode.
* - LV connected:
Expand All @@ -72,7 +88,8 @@ export function readDomItemRedirect(
* Returns true when a redirect was attempted, false otherwise.
*/
export function performRedirect(input: RedirectInput | null, ctx: RedirectContext): boolean {
if (!input || !input.destination) return false;
if (!input || !input.destination || !isAllowedRedirectDestination(input.destination))
return false;
const { destination, newTab, mode } = input;

if (newTab) {
Expand Down
46 changes: 45 additions & 1 deletion assets/test/lib/redirect.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mockLiveSocket } from "../helpers/mock-live-socket";
import { performRedirect, readDomItemRedirect } from "../../lib/redirect";
import {
isAllowedRedirectDestination,
performRedirect,
readDomItemRedirect,
} from "../../lib/redirect";

describe("isAllowedRedirectDestination", () => {
it.each([
["/items", true],
["./relative", true],
["?page=2", true],
["https://example.com/path", true],
["http://localhost:4000", true],
["javascript:alert(1)", false],
["//evil.example", false],
["data:text/html,hi", false],
["vbscript:msgbox", false],
])("%s -> %s", (destination, allowed) => {
expect(isAllowedRedirectDestination(destination)).toBe(allowed);
});
});

describe("readDomItemRedirect", () => {
it("returns null without element or fallback", () => {
Expand All @@ -11,6 +31,16 @@ describe("readDomItemRedirect", () => {
expect(readDomItemRedirect(null, "/items")).toEqual({ destination: "/items" });
});

it("returns null for disallowed fallback", () => {
expect(readDomItemRedirect(null, "javascript:alert(1)")).toBeNull();
});

it("returns null for disallowed data-to", () => {
const el = document.createElement("div");
el.setAttribute("data-to", "javascript:alert(1)");
expect(readDomItemRedirect(el)).toBeNull();
});

it("opts out with data-redirect=false", () => {
const el = document.createElement("div");
el.setAttribute("data-redirect", "false");
Expand Down Expand Up @@ -67,4 +97,18 @@ describe("performRedirect", () => {
expect(navigate).toHaveBeenCalledWith("/nav");
expect(patch).not.toHaveBeenCalled();
});

it("rejects javascript URLs", () => {
const { ctx, patch, navigate } = mockLiveSocket(true);
expect(performRedirect({ destination: "javascript:alert(1)" }, ctx)).toBe(false);
expect(openSpy).not.toHaveBeenCalled();
expect(patch).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});

it("rejects protocol-relative URLs", () => {
const { ctx } = mockLiveSocket(true);
expect(performRedirect({ destination: "//evil.example", newTab: true }, ctx)).toBe(false);
expect(openSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
// lib/redirect.ts
var REDIRECT_MODES = ["href", "patch", "navigate"];
var SCHEME_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
function isAllowedRedirectDestination(destination) {
const trimmed = destination.trim();
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;
const schemeMatch = SCHEME_PREFIX.exec(trimmed);
if (schemeMatch) {
const scheme = schemeMatch[0].slice(0, -1).toLowerCase();
return scheme === "http" || scheme === "https";
}
return true;
}
function readDomItemRedirect(itemEl, fallback) {
if (!itemEl) {
if (!fallback) return null;
if (!fallback || !isAllowedRedirectDestination(fallback)) return null;
return { destination: fallback };
}
const dataRedirect = itemEl.getAttribute("data-redirect");
if (dataRedirect === "false") return null;
const destination = itemEl.getAttribute("data-to") || fallback || itemEl.getAttribute("data-value") || "";
if (!destination) return null;
if (!destination || !isAllowedRedirectDestination(destination)) return null;
const mode = REDIRECT_MODES.includes(dataRedirect) ? dataRedirect : void 0;
const newTab = itemEl.hasAttribute("data-new-tab");
return { destination, mode, newTab };
}
function performRedirect(input, ctx) {
if (!input || !input.destination) return false;
if (!input || !input.destination || !isAllowedRedirectDestination(input.destination))
return false;
const { destination, newTab, mode } = input;
if (newTab) {
window.open(destination, "_blank", "noopener,noreferrer");
Expand Down
2 changes: 1 addition & 1 deletion priv/static/combobox.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
import {
performRedirect,
readDomItemRedirect
} from "./chunks/chunk-FOQSALVP.mjs";
} from "./chunks/chunk-6Q6MB27T.mjs";
import {
getInteractionModality,
setInteractionModality,
Expand Down
37 changes: 25 additions & 12 deletions priv/static/corex.js
Original file line number Diff line number Diff line change
Expand Up @@ -13000,22 +13000,34 @@ var Corex = (() => {
}
});

// ../priv/static/chunks/chunk-FOQSALVP.mjs
// ../priv/static/chunks/chunk-6Q6MB27T.mjs
function isAllowedRedirectDestination(destination) {
const trimmed = destination.trim();
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;
const schemeMatch = SCHEME_PREFIX.exec(trimmed);
if (schemeMatch) {
const scheme = schemeMatch[0].slice(0, -1).toLowerCase();
return scheme === "http" || scheme === "https";
}
return true;
}
function readDomItemRedirect(itemEl, fallback2) {
if (!itemEl) {
if (!fallback2) return null;
if (!fallback2 || !isAllowedRedirectDestination(fallback2)) return null;
return { destination: fallback2 };
}
const dataRedirect = itemEl.getAttribute("data-redirect");
if (dataRedirect === "false") return null;
const destination = itemEl.getAttribute("data-to") || fallback2 || itemEl.getAttribute("data-value") || "";
if (!destination) return null;
if (!destination || !isAllowedRedirectDestination(destination)) return null;
const mode = REDIRECT_MODES.includes(dataRedirect) ? dataRedirect : void 0;
const newTab = itemEl.hasAttribute("data-new-tab");
return { destination, mode, newTab };
}
function performRedirect(input, ctx) {
if (!input || !input.destination) return false;
if (!input || !input.destination || !isAllowedRedirectDestination(input.destination))
return false;
const { destination, newTab, mode } = input;
if (newTab) {
window.open(destination, "_blank", "noopener,noreferrer");
Expand All @@ -13035,11 +13047,12 @@ var Corex = (() => {
}
return true;
}
var REDIRECT_MODES;
var init_chunk_FOQSALVP = __esm({
"../priv/static/chunks/chunk-FOQSALVP.mjs"() {
var REDIRECT_MODES, SCHEME_PREFIX;
var init_chunk_6Q6MB27T = __esm({
"../priv/static/chunks/chunk-6Q6MB27T.mjs"() {
"use strict";
REDIRECT_MODES = ["href", "patch", "navigate"];
SCHEME_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
}
});

Expand Down Expand Up @@ -13590,7 +13603,7 @@ var Corex = (() => {
init_chunk_VJGUNSK5();
init_chunk_OAGPTRUC();
init_chunk_4PIYPYVK();
init_chunk_FOQSALVP();
init_chunk_6Q6MB27T();
init_chunk_V4PB2O2G();
init_chunk_H5X7JSOZ();
init_chunk_77HPO22C();
Expand Down Expand Up @@ -27163,7 +27176,7 @@ ${err}`);
"use strict";
init_chunk_OAGPTRUC();
init_chunk_4PIYPYVK();
init_chunk_FOQSALVP();
init_chunk_6Q6MB27T();
init_chunk_V4PB2O2G();
init_chunk_H5X7JSOZ();
init_chunk_77HPO22C();
Expand Down Expand Up @@ -28525,7 +28538,7 @@ ${err}`);
init_chunk_57TWBSTW();
init_chunk_4QMNVH3P();
init_chunk_VJGUNSK5();
init_chunk_FOQSALVP();
init_chunk_6Q6MB27T();
init_chunk_V4PB2O2G();
init_chunk_2WCNJX5P();
init_chunk_EWT2BP2N();
Expand Down Expand Up @@ -34592,7 +34605,7 @@ ${err}`);
init_chunk_VJGUNSK5();
init_chunk_OAGPTRUC();
init_chunk_4PIYPYVK();
init_chunk_FOQSALVP();
init_chunk_6Q6MB27T();
init_chunk_V4PB2O2G();
init_chunk_H5X7JSOZ();
init_chunk_77HPO22C();
Expand Down Expand Up @@ -43445,7 +43458,7 @@ ${err}`);
init_chunk_JDGMEOQK();
init_chunk_XI7CXJ3V();
init_chunk_4PIYPYVK();
init_chunk_FOQSALVP();
init_chunk_6Q6MB27T();
init_chunk_77HPO22C();
init_chunk_2WCNJX5P();
init_chunk_EWT2BP2N();
Expand Down
Loading
Loading