Reject dangerous URL schemes in menu custom links#94
Reject dangerous URL schemes in menu custom links#94ascorbic merged 3 commits intoemdash-cms:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 9ffe284 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
|
All contributors have signed the CLA ✍️ ✅ |
There was a problem hiding this comment.
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
customUrlusing 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.
| const safeHref = z | ||
| .string() | ||
| .refine(isSafeHref, "URL must use http, https, mailto, tel, or a relative path"); |
There was a problem hiding this comment.
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.
| 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", | |
| ); |
| const menuItemType = z.string().min(1); | ||
|
|
||
| const safeHref = z | ||
| .string() |
There was a problem hiding this comment.
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).
| .string() | |
| .string() | |
| .trim() |
|
I have read the CLA Document and I hereby sign the CLA |
BenjaminPrice
left a comment
There was a problem hiding this comment.
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:andtel:
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
|
there are some things to fix here:
|
|
@ascorbic could you commit those changes? under line 448 |
|
oh and also a changeset is missing |
|
Thanks to Nick Gray for sponsoring my time on this review |
|
@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
256a8ee to
31c0db7
Compare


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 likejavascript:alert(1)that executed when visitors clicked the link.The fix validates
customUrlin the Zod schema against an allowlist of safe schemes (http, https, mailto, tel, relative paths, fragments) and sanitizes URLs at the render layer inresolveMenuItem()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
Checklist
pnpm typecheckpassespnpm --silent lint:json | jq '.diagnostics | length'returns 0pnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
N/A