Repository navigation
fix(core): restrict url field values to safe link schemes - #3327
Conversation
`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 detectedLatest commit: c7ddaf1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
Scope checkThis 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. |
There was a problem hiding this comment.
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):isSafeUrlFieldValueallow-listshttp:/https:/mailto:/tel:, site-relative paths, and fragments; it rejects protocol-relative forms (//host,/\host) and values containing tab/CR/LF.hasUnsafeUrlSchemestrips control/whitespace the way browsers do before resolving an href, so\tjava\nscript:still resolves tojavascript:and is caught. - Schema validation (
packages/core/src/schema/zod-generator.ts): URL fields now use a custominvalid_formatissue withformat: "url", matching the admin localization incontent-validation-errors.ts. Repeaterurlsub-fields inherit the same check throughgenerateRepeaterRowSchema. - Repository write path (
packages/core/src/database/content-datetime.ts):normalizeInputreuses the existing per-collection field-type lookup to assert safe URL schemes oncreate,update, andupdateDraftAwarewithout 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 fromtype="url"totype="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.mdxand the changeset accurately describe the new allowed set, the legacy-value behavior, and thesanitizeHref()migration guidance; thesanitizeHref/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.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-test
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-loader
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@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-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | c7ddaf1 | Sep 26 2026, 07:42 PM |
🚀 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 deploymentPreview Deployments by commit
|
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
# Conflicts: # packages/core/src/schema/zod-generator.ts
What does this PR do?
urlcontent fields previously accepted schemes such asjavascript:anddata:. 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
urlfield, including URL values nested in repeaters and blocks, accepts:http:andhttps:URLsmailto:andtel:links/about#contactScript-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
generateFieldSchemauses the strictisSafeUrlFieldValuepredicate.create,update, andupdateDraftAwareuseisSafeUrlFieldWriteValue. This preserves compatible scheme-less legacy values such aswww.example.com, while rejecting script schemes and values browsers resolve off-origin. Repeater values encoded as JSON strings are checked too.sanitizeHref()andisSafeHref()now reject protocol-relative, backslash-normalized, and control-character forms in addition to unsafe schemes.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:
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
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.The Discussion item is not applicable because this is a bug fix.
AI-generated code disclosure
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
Arabic RTL
Rendered verification used the real admin at 1440×900.
html[dir]wasrtlin Arabic, the URL input'sdirwasltrin both locales, and the browser reported no runtime errors.Validation completed on the repaired head:
pnpm formatpnpm buildpnpm lint:quickpnpm --silent lint:json | jq '.diagnostics | length'→0pnpm typecheckpnpm --dir docs buildContentEditorbrowser-component tests