Skip to content

fix(core): restrict url field values to safe link schemes - #3327

Merged
ascorbic merged 4 commits into
mainfrom
fix/url-field-safe-schemes
Sep 26, 2026
Merged

ascorbic merged 4 commits into
mainfrom
fix/url-field-safe-schemes

Conversation

@ascorbic

@ascorbic ascorbic commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

url content fields previously accepted schemes such as javascript: and data:. A theme rendering one directly into <a href> could therefore create stored XSS on the site origin, which is shared with the admin.

This PR applies one consistent safe-link policy at the public API, repository, transfer-import, rendering-helper, and admin boundaries.

Allowed values

A url field, including URL values nested in repeaters and blocks, accepts:

  • http: and https: URLs
  • mailto: and tel: links
  • site-relative paths such as /about
  • fragments such as #contact

Script-capable schemes, protocol-relative URLs, control characters, and backslash forms that browsers normalize to another origin are rejected. Examples include //host, /\\host, /\t/host, and control-prefixed variants.

Write and render paths

  • REST, MCP, WordPress imports, plugin create paths, and the admin editor: generateFieldSchema uses the strict isSafeUrlFieldValue predicate.
  • Seeds and direct repository writes used by plugins: create, update, and updateDraftAware use isSafeUrlFieldWriteValue. This preserves compatible scheme-less legacy values such as www.example.com, while rejecting script schemes and values browsers resolve off-origin. Repeater values encoded as JSON strings are checked too.
  • Blocks: URL fields and repeater URL sub-fields inside block values use the same validation.
  • Site transfer import: analysis rejects unsafe URL values in entries and revisions, including nested repeaters and versioned blocks, before the import can run.
  • Rendering older content: sanitizeHref() and isSafeHref() now reject protocol-relative, backslash-normalized, and control-character forms in addition to unsafe schemes.
  • Admin editor: the URL input accepts the documented safe values and remains left-to-right inside RTL locales.

The repository check reuses the field metadata lookup already performed by the datetime normalizer, so it does not add a query to content writes.

Backwards compatibility

Existing stored values are not modified or hidden:

  • Queries return them unchanged.
  • Revision restores and translation syncs do not revalidate stored values.
  • Updates that do not send the field still succeed.

A write that sends an unsafe value again is refused until the value is corrected. That includes duplicating the entry. Transfer-analysis state additions are optional/defaulted so an existing version-1 analysis state remains readable.

The field-types and API references document the accepted values, direct-write compatibility, transfer behavior, and render-time helper.

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
  • I have included screenshots below if this PR changes the UI

The Discussion item is not applicable because this is a bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code — models/tools: Claude Opus 5.5 (Claude Code), gpt-5.6-sol (Codex)

Screenshots / test output

The field is explicitly left-to-right in both screenshots. The Arabic page retains its RTL layout while the mixed Latin/Arabic URL keeps browser-standard URL ordering.

English

English content editor showing the Canonical URL field with a mixed Latin and Arabic URL

Arabic RTL

Arabic RTL content editor showing the Canonical URL field remaining left-to-right

Rendered verification used the real admin at 1440×900. html[dir] was rtl in Arabic, the URL input's dir was ltr in both locales, and the browser reported no runtime errors.

Validation completed on the repaired head:

  • pnpm format
  • pnpm build
  • pnpm lint:quick
  • pnpm --silent lint:json | jq '.diagnostics | length' → 0
  • pnpm typecheck
  • pnpm --dir docs build
  • 152 focused core tests covering URL helpers, repository writes, real REST and MCP boundaries, repeaters, blocks, and transfer analysis
  • 138 ContentEditor browser-component tests

`url` fields were validated with z.url(), which accepts javascript: and
data: URLs, so themes rendering <a href={entry.data.website}> got stored
XSS on the site origin (shared with the admin).

- REST, MCP, imports and plugin creates: url fields and repeater url
  sub-fields accept http(s), mailto:, tel:, site-relative paths and
  fragments. Protocol-relative forms (//host, /\host, paths containing
  tab/CR/LF) are rejected. Issues keep the invalid_format/url shape the
  admin localizes.
- Seeds and plugin updates (in-process and sandbox bridges) bypass field
  validation, so ContentRepository create/update/updateDraftAware now
  reject script-capable schemes in incoming data, including repeaters
  sent as JSON strings. The check reuses the field lookup the datetime
  normalizer already runs, so it adds no queries. It is scheme-only so
  scheme-less values seeds could always write keep working.
- Stored values are untouched: reads, revision restores and translation
  syncs are not checked; writes that resend a bad value are refused.
- Admin editor accepts the newly allowed forms (text input with
  inputMode=url, since type=url's native validation blocks relative
  paths) and trims pasted whitespace on blur.
@changeset-bot

changeset-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c7ddaf1

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

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds 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

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 504 lines across 10 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the right fix for a real stored-XSS vector: url content fields previously accepted javascript: and data: via z.url(), and the change aligns them with the existing isSafeHref allowlist used by menus/bylines.

What I checked:

  • Validation predicates (packages/core/src/utils/url.ts): isSafeUrlFieldValue allow-lists http:/https:/mailto:/tel:, site-relative paths, and fragments; it rejects protocol-relative forms (//host, /\host) and values containing tab/CR/LF. hasUnsafeUrlScheme strips control/whitespace the way browsers do before resolving an href, so \tjava\nscript: still resolves to javascript: and is caught.
  • Schema validation (packages/core/src/schema/zod-generator.ts): URL fields now use a custom invalid_format issue with format: "url", matching the admin localization in content-validation-errors.ts. Repeater url sub-fields inherit the same check through generateRepeaterRowSchema.
  • Repository write path (packages/core/src/database/content-datetime.ts): normalizeInput reuses the existing per-collection field-type lookup to assert safe URL schemes on create, update, and updateDraftAware without adding a query round-trip. Reads/restore/sync paths are intentionally left unchanged, so legacy values stay readable.
  • Admin editor (packages/admin/src/components/ContentEditor.tsx): switched from type="url" to type="text" inputMode="url" so relative paths/fragments/mailto/tel pass, and blur trims surrounding whitespace.
  • Tests: integration tests cover REST/MCP/runtime and repository writes, repeaters, JSON-string repeaters, legacy stored values, and dialect parity; admin and Zod unit tests cover the new allowed/rejected shapes.
  • Docs/changeset: field-types.mdx and the changeset accurately describe the new allowed set, the legacy-value behavior, and the sanitizeHref() migration guidance; the sanitizeHref/anchor in the API docs exists.

No blocking issues. The only mild rough edge is that ContentDatetimeNormalizer now validates URLs as well as datetimes, which is broader than its name suggests, but the method/comment make that explicit and the trade-off avoids an extra query.

@pkg-pr-new

pkg-pr-new Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3327

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3327

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3327

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3327

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3327

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3327

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3327

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3327

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3327

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3327

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: c7ddaf1

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-review No maintainer or bot review yet labels Sep 23, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs c7ddaf1 Sep 26 2026, 07:42 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://fix-url-field-safe-schemes.try.emdashcms.com, https://fix-url-field-safe-schemes-emdash-playground.emdash-cms.workers.dev (commit c7ddaf1)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://a1fcf339.try.emdashcms.com, https://a1fcf339-emdash-playground.emdash-cms.workers.dev c7ddaf1 2026-09-26T19:42:57.823Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e20b616a.try.emdashcms.com, https://e20b616a-emdash-playground.emdash-cms.workers.dev 89d530d 2026-09-26T13:50:54.314Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://63d3e71d.try.emdashcms.com, https://63d3e71d-emdash-playground.emdash-cms.workers.dev 88a2ca0 2026-09-23T13:57:09.192Z Visit the dashboard ↗

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed needs-rebase review/approved Approved; no new commits since labels Sep 26, 2026
@ascorbic
ascorbic merged commit f796444 into main Sep 26, 2026
41 checks passed
@ascorbic
ascorbic deleted the fix/url-field-safe-schemes branch September 26, 2026 21:13
@emdashbot emdashbot Bot mentioned this pull request Sep 26, 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.

1 participant