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
5 changes: 5 additions & 0 deletions .changeset/curly-pens-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Reject dangerous URL schemes in menu custom links
14 changes: 12 additions & 2 deletions packages/core/src/api/schemas/menus.ts
Original file line number Diff line number Diff line change
@@ -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()
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider normalizing the input before validation (e.g., trimming leading/trailing whitespace) to avoid inconsistent acceptance/rejection and reduce scheme-obfuscation surface area. A straightforward approach is to add .trim() before .refine(...), or ensure equivalent normalization is performed inside isSafeHref (and keep tests for whitespace/control-character variants if you choose to support them).

Suggested change
.string()
.string()
.trim()

Copilot uses AI. Check for mistakes.
.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),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/menus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Expand Down
213 changes: 213 additions & 0 deletions packages/core/tests/unit/menus/menus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Database>;
Expand Down Expand Up @@ -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,<script>alert(1)</script>",
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();
Expand Down Expand Up @@ -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,<script>alert(1)</script>",
});
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("#");
});
});
});
Loading