Skip to content

Reject dangerous URL schemes in menu custom links#94

Merged
ascorbic merged 3 commits intoemdash-cms:mainfrom
eyupcanakman:fix/menu-xss-javascript-uri
Apr 11, 2026
Merged

Reject dangerous URL schemes in menu custom links#94
ascorbic merged 3 commits intoemdash-cms:mainfrom
eyupcanakman:fix/menu-xss-javascript-uri

Conversation

@eyupcanakman
Copy link
Copy Markdown
Contributor

@eyupcanakman eyupcanakman commented Apr 2, 2026

What does this PR do?

Closes #89

The menu editor accepted any URL for custom links, including javascript: URIs. A user with menu permissions could save a payload like javascript:alert(1) that executed when visitors clicked the link.

The fix validates customUrl in the Zod schema against an allowlist of safe schemes (http, https, mailto, tel, relative paths, fragments) and sanitizes URLs at the render layer in resolveMenuItem() so pre-existing data in the database is also covered.

New tests cover the reject and allow cases for both schemas and the render path.

Type of change

  • Bug fix
  • Feature (requires approved Discussion)
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm --silent lint:json | jq '.diagnostics | length' returns 0
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

N/A

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 2, 2026

🦋 Changeset detected

Latest commit: 9ffe284

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-ai-moderation Patch
@emdash-cms/plugin-atproto Patch
@emdash-cms/plugin-audit-log Patch
@emdash-cms/plugin-color Patch
@emdash-cms/plugin-embeds Patch
@emdash-cms/plugin-forms Patch
@emdash-cms/plugin-webhook-notifier Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI review requested due to automatic review settings April 5, 2026 07:40
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 5, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@94

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@94

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@94

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@94

emdash

npm i https://pkg.pr.new/emdash@94

create-emdash

npm i https://pkg.pr.new/create-emdash@94

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@94

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@94

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@94

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@94

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@94

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@94

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@94

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@94

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@94

commit: 9ffe284

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR mitigates XSS risk in navigation menu custom links by validating and sanitizing menu item URLs, covering both new inputs and existing database records.

Changes:

  • Adds Zod schema validation for customUrl using a safe-scheme allowlist.
  • Sanitizes resolved menu item URLs at render time to neutralize unsafe persisted data.
  • Introduces unit tests for rejected/allowed URLs and render-layer sanitization.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/core/tests/unit/menus/menus.test.ts Adds tests for schema validation and database-backed URL sanitization.
packages/core/src/menus/index.ts Sanitizes resolved menu item url values during menu building.
packages/core/src/api/schemas/menus.ts Enforces safe URL validation for customUrl in create/update schemas.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +11 to +13
const safeHref = z
.string()
.refine(isSafeHref, "URL must use http, https, mailto, tel, or a relative path");
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.

The validation message doesn’t match the behavior described/tested (fragment-only links like #section are allowed per tests/PR description, but the message doesn’t mention fragments). Update the message to explicitly include fragments (and any other allowed forms your isSafeHref supports) so API consumers get accurate feedback.

Suggested change
const safeHref = z
.string()
.refine(isSafeHref, "URL must use http, https, mailto, tel, or a relative path");
const safeHref = z.string().refine(
isSafeHref,
"URL must use http, https, mailto, tel, a relative path, or a fragment identifier",
);

Copilot uses AI. Check for mistakes.
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.
@eyupcanakman
Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

github-actions bot added a commit that referenced this pull request Apr 5, 2026
Copy link
Copy Markdown
Contributor

@BenjaminPrice BenjaminPrice left a comment

Choose a reason for hiding this comment

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

Tested scenarios

  • Step 4.1 passed: Happy path: create a menu item with a safe URL
  • Step 4.2 passed: Reject javascript: URL on create
  • Step 4.3 passed: Reject javascript: URL on update
  • Step 4.4 passed: Reject case-varied scheme (JaVaScRiPt:)
  • Step 4.5 passed: Reject data: URL
  • Step 4.8 passed: Allow mailto: and tel:

Outstanding

  • Changed/added but untested: Whitespace/control-character prefixed URLs (e.g. \tjavascript:alert(1), https://x.com) → unit test in menus.test.ts → createMenuItemBody.safeParse with leading whitespace/tab to document current behaviour. tel: URL acceptance → unit test in menus.test.ts → parse tel:+1234567890 and assert success (currently only mailto: has an allow test; tel: is claimed safe by the regex but untested). sanitizeHref with null/undefined input → unit test in a new url.test.ts → assert returns "#" (the function handles it, but no test covers it). data: and vbscript: at the render layer → unit test in menus.test.ts → insert these schemes into the DB and assert sanitisation to "#" (only javascript: is tested at the render layer).

Nits

  • Copilot concerns are worth addressing

@JULJERYT
Copy link
Copy Markdown
Contributor

JULJERYT commented Apr 6, 2026

there are some things to fix here:

  1. the error message omits fragment links - copilot is right here
  2. no test for tel: URLs in the schema tests
  3. the safeHref validator rejects empty strings (correct), but there's no test asserting that customUrl: "" is rejected

@JULJERYT
Copy link
Copy Markdown
Contributor

JULJERYT commented Apr 6, 2026

@ascorbic could you commit those changes?
line 13 packages/core/src/api/schemas/menus.ts

	.trim()
	.refine(isSafeHref, "URL must use http, https, mailto, tel, a relative path, or a fragment identifier");

like this:
image

under line 448 packages/core/tests/unit/menus/menus.test.ts

		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);
		});

like this
image

@JULJERYT
Copy link
Copy Markdown
Contributor

JULJERYT commented Apr 6, 2026

oh and also a changeset is missing

---
"emdash": patch
---

Reject dangerous URL schemes in menu custom links

@JULJERYT
Copy link
Copy Markdown
Contributor

JULJERYT commented Apr 6, 2026

Thanks to Nick Gray for sponsoring my time on this review

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Apr 6, 2026

@JULJERYT thanks for reviewing. Suggestions are to the PR author, and can be made with suggestions in the files view: https://github.com/emdash-cms/emdash/pull/94/changes

- Add render-layer sanitization tests for data: and vbscript: URLs
- Add schema validation tests for tel:, empty string, whitespace trim
- Add sanitizeHref utility tests for null/undefined input
- Trim whitespace before URL scheme validation in Zod schema
@eyupcanakman eyupcanakman force-pushed the fix/menu-xss-javascript-uri branch from 256a8ee to 31c0db7 Compare April 11, 2026 08:36
@github-actions github-actions bot added size/L and removed size/M labels Apr 11, 2026
Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Thanks!

@ascorbic ascorbic enabled auto-merge (squash) April 11, 2026 08:53
@ascorbic ascorbic merged commit da957ce into emdash-cms:main Apr 11, 2026
24 of 25 checks passed
@emdashbot emdashbot bot mentioned this pull request Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stored XSS via javascript: URI in navigation menu editor

5 participants