diff --git a/.changeset/curly-pens-glow.md b/.changeset/curly-pens-glow.md new file mode 100644 index 000000000..bf6c7ba45 --- /dev/null +++ b/.changeset/curly-pens-glow.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Reject dangerous URL schemes in menu custom links diff --git a/packages/core/src/api/schemas/menus.ts b/packages/core/src/api/schemas/menus.ts index 458088da9..8a1a8dc44 100644 --- a/packages/core/src/api/schemas/menus.ts +++ b/packages/core/src/api/schemas/menus.ts @@ -1,11 +1,21 @@ import { z } from "zod"; +import { isSafeHref } from "../../utils/url.js"; + // --------------------------------------------------------------------------- // Menus: Input schemas // --------------------------------------------------------------------------- const menuItemType = z.string().min(1); +const safeHref = z + .string() + .trim() + .refine( + isSafeHref, + "URL must use http, https, mailto, tel, a relative path, or a fragment identifier", + ); + export const createMenuBody = z .object({ name: z.string().min(1), @@ -25,7 +35,7 @@ export const createMenuItemBody = z label: z.string().min(1), referenceCollection: z.string().optional(), referenceId: z.string().optional(), - customUrl: z.string().optional(), + customUrl: safeHref.optional(), target: z.string().optional(), titleAttr: z.string().optional(), cssClasses: z.string().optional(), @@ -37,7 +47,7 @@ export const createMenuItemBody = z export const updateMenuItemBody = z .object({ label: z.string().min(1).optional(), - customUrl: z.string().optional(), + customUrl: safeHref.optional(), target: z.string().optional(), titleAttr: z.string().optional(), cssClasses: z.string().optional(), diff --git a/packages/core/src/menus/index.ts b/packages/core/src/menus/index.ts index 3c47be749..e9cb6bc7a 100644 --- a/packages/core/src/menus/index.ts +++ b/packages/core/src/menus/index.ts @@ -9,6 +9,7 @@ import { sql } from "kysely"; import type { Database } from "../database/types.js"; import { getDb } from "../loader.js"; +import { sanitizeHref } from "../utils/url.js"; import type { Menu, MenuItem, MenuItemRow } from "./types.js"; /** @@ -235,7 +236,7 @@ async function resolveMenuItem( return { id: item.id, label: item.label, - url, + url: sanitizeHref(url), target: item.target || undefined, titleAttr: item.title_attr || undefined, cssClasses: item.css_classes || undefined, diff --git a/packages/core/tests/unit/menus/menus.test.ts b/packages/core/tests/unit/menus/menus.test.ts index 39300c19b..c2c4f6ca4 100644 --- a/packages/core/tests/unit/menus/menus.test.ts +++ b/packages/core/tests/unit/menus/menus.test.ts @@ -2,10 +2,12 @@ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createMenuItemBody, updateMenuItemBody } from "../../../src/api/schemas/menus.js"; import { createDatabase } from "../../../src/database/connection.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import type { Database } from "../../../src/database/types.js"; import { getMenuWithDb, getMenusWithDb } from "../../../src/menus/index.js"; +import { sanitizeHref } from "../../../src/utils/url.js"; describe("Navigation Menus", () => { let db: Kysely; @@ -206,6 +208,87 @@ describe("Navigation Menus", () => { }); }); + it("should sanitize dangerous URLs from the database", async () => { + const menuId = ulid(); + const itemId = ulid(); + + await db + .insertInto("_emdash_menus") + .values({ id: menuId, name: "primary", label: "Primary" }) + .execute(); + + await db + .insertInto("_emdash_menu_items") + .values({ + id: itemId, + menu_id: menuId, + sort_order: 0, + type: "custom", + custom_url: "javascript:alert(1)", + label: "XSS", + }) + .execute(); + + const menu = await getMenuWithDb("primary", db); + expect(menu).not.toBeNull(); + expect(menu!.items).toHaveLength(1); + expect(menu!.items[0].url).toBe("#"); + }); + + it("should sanitize data: URLs from the database", async () => { + const menuId = ulid(); + const itemId = ulid(); + + await db + .insertInto("_emdash_menus") + .values({ id: menuId, name: "primary", label: "Primary" }) + .execute(); + + await db + .insertInto("_emdash_menu_items") + .values({ + id: itemId, + menu_id: menuId, + sort_order: 0, + type: "custom", + custom_url: "data:text/html,", + label: "XSS", + }) + .execute(); + + const menu = await getMenuWithDb("primary", db); + expect(menu).not.toBeNull(); + expect(menu!.items).toHaveLength(1); + expect(menu!.items[0].url).toBe("#"); + }); + + it("should sanitize vbscript: URLs from the database", async () => { + const menuId = ulid(); + const itemId = ulid(); + + await db + .insertInto("_emdash_menus") + .values({ id: menuId, name: "primary", label: "Primary" }) + .execute(); + + await db + .insertInto("_emdash_menu_items") + .values({ + id: itemId, + menu_id: menuId, + sort_order: 0, + type: "custom", + custom_url: "vbscript:MsgBox", + label: "XSS", + }) + .execute(); + + const menu = await getMenuWithDb("primary", db); + expect(menu).not.toBeNull(); + expect(menu!.items).toHaveLength(1); + expect(menu!.items[0].url).toBe("#"); + }); + it("should skip items with deleted content references", async () => { const menuId = ulid(); const itemId = ulid(); @@ -338,4 +421,134 @@ describe("Navigation Menus", () => { expect(menu!.items[2].label).toBe("Contact"); }); }); + + describe("menu item URL validation", () => { + it("should reject javascript: URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "XSS", + customUrl: "javascript:alert(1)", + }); + expect(result.success).toBe(false); + }); + + it("should reject data: URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "XSS", + customUrl: "data:text/html,", + }); + expect(result.success).toBe(false); + }); + + it("should reject vbscript: URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "XSS", + customUrl: "vbscript:MsgBox", + }); + expect(result.success).toBe(false); + }); + + it("should allow https URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Link", + customUrl: "https://example.com", + }); + expect(result.success).toBe(true); + }); + + it("should allow relative paths", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Link", + customUrl: "/about", + }); + expect(result.success).toBe(true); + }); + + it("should allow fragment links", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Link", + customUrl: "#section", + }); + expect(result.success).toBe(true); + }); + + it("should reject case-varied javascript: URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "XSS", + customUrl: "JAVASCRIPT:alert(1)", + }); + expect(result.success).toBe(false); + }); + + it("should allow mailto URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Email", + customUrl: "mailto:user@example.com", + }); + expect(result.success).toBe(true); + }); + + it("should reject javascript: in update schema", () => { + const result = updateMenuItemBody.safeParse({ + customUrl: "javascript:alert(1)", + }); + expect(result.success).toBe(false); + }); + + it("should allow tel: URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Call", + customUrl: "tel:+15551234567", + }); + expect(result.success).toBe(true); + }); + + it("should reject empty string URLs", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Link", + customUrl: "", + }); + expect(result.success).toBe(false); + }); + + it("should trim whitespace before validating", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "Link", + customUrl: " https://example.com ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customUrl).toBe("https://example.com"); + } + }); + + it("should reject whitespace-prefixed javascript: after trim", () => { + const result = createMenuItemBody.safeParse({ + type: "custom", + label: "XSS", + customUrl: " javascript:alert(1)", + }); + expect(result.success).toBe(false); + }); + }); + + describe("sanitizeHref", () => { + it("should return # for null input", () => { + expect(sanitizeHref(null)).toBe("#"); + }); + + it("should return # for undefined input", () => { + expect(sanitizeHref(undefined)).toBe("#"); + }); + }); });