Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/__tests__/property-controls/bindingCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,18 @@ describe('isFieldBindable', () => {
expect(isFieldBindable('url', metaField('f', 'email'))).toBe(true)
})

it('url + text → true (a slug composes into an href token)', () => {
expect(isFieldBindable('url', metaField('f', 'text'))).toBe(true)
})

it('url + boolean → false', () => {
expect(isFieldBindable('url', metaField('f', 'boolean'))).toBe(false)
})

it('url + richText → false (markup is not a URL fragment)', () => {
expect(isFieldBindable('url', metaField('f', 'richText'))).toBe(false)
})

it('group + text → false (group has no bindings)', () => {
expect(isFieldBindable('group', metaField('f', 'text'))).toBe(false)
})
Expand Down
51 changes: 44 additions & 7 deletions src/__tests__/utils/urlValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,48 @@ describe('isValidUrl', () => {
})
})

describe('malformed input', () => {
it('rejects plain text without protocol', () => {
expect(isValidUrl('not a url')).toBe(false)
describe('relative references', () => {
it('accepts a root-relative path', () => {
expect(isValidUrl('/relative/path')).toBe(true)
})

it('accepts document-relative paths', () => {
expect(isValidUrl('./sibling.html')).toBe(true)
expect(isValidUrl('../parent/page')).toBe(true)
expect(isValidUrl('page.html')).toBe(true)
})

it('accepts in-page anchors and query-only references', () => {
expect(isValidUrl('#section')).toBe(true)
expect(isValidUrl('?q=1')).toBe(true)
})

it('accepts protocol-relative URLs', () => {
expect(isValidUrl('//example.com')).toBe(true)
})
})

describe('data tokens', () => {
it('accepts a bare token', () => {
expect(isValidUrl('{currentEntry.permalink}')).toBe(true)
})

it('rejects relative paths', () => {
expect(isValidUrl('/relative/path')).toBe(false)
it('accepts a token composed into a relative path', () => {
expect(isValidUrl('/blog/{currentEntry.slug}')).toBe(true)
})

it('rejects protocol-relative URLs', () => {
expect(isValidUrl('//example.com')).toBe(false)
it('accepts a token composed into an absolute URL', () => {
expect(isValidUrl('https://example.com/{currentEntry.slug}')).toBe(true)
})
})

describe('malformed input', () => {
it('rejects plain text containing spaces', () => {
expect(isValidUrl('not a url')).toBe(false)
})

it('rejects a relative path containing whitespace', () => {
expect(isValidUrl('/relative/pa th')).toBe(false)
})
})
})
Expand Down Expand Up @@ -176,5 +207,11 @@ describe('isValidImageUrl', () => {
expect(isValidUrl(url)).toBe(false)
expect(isValidImageUrl(url)).toBe(true)
})

it('relative references are valid for isValidUrl but not isValidImageUrl', () => {
const url = '/images/photo.png'
expect(isValidUrl(url)).toBe(true)
expect(isValidImageUrl(url)).toBe(false)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export function loopFieldMatchesControl(
case 'textarea':
return field.format !== 'media' && field.format !== 'html'
case 'url':
return field.format === 'url' || field.format === 'media'
// Same reasoning as BINDING_COMPATIBILITY.url: token insertion builds the
// href as a string, so plain-text synthetics (`slug`, `publishedAt`, …)
// are valid path segments. Only html is excluded.
return field.format !== 'html'
case 'number':
case 'toggle':
case 'color':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export const BINDING_COMPATIBILITY: Record<PropertyControlKind, readonly DataFie
// to a data field.
svg: [],
number: ['number'],
url: ['url', 'email'],
// url binds in token mode: the picker inserts `{source.field}` into the href
// string, so any scalar that reads as a path segment is a legitimate source —
// linking a loop row to its template page means composing the URL out of the
// row's `slug` (a plain text field). richText is excluded (markup is not a
// URL fragment), as are multiSelect (comma-joined) and boolean.
url: ['url', 'email', 'text', 'longText', 'select', 'relation', 'number', 'date', 'dateTime'],
color: [],
toggle: ['boolean'],
select: [],
Expand Down
29 changes: 23 additions & 6 deletions src/core/utils/urlValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* scheme is — they differ only in which schemes they accept.
*
* Allowlists:
* isValidUrl() — https, http, mailto
* isValidUrl() — https, http, mailto, plus every schemeless (relative)
* form, matching what `isSafeUrl()` lets through
* isValidImageUrl() — https, http, data:image/* (inline base64 images)
*/

Expand Down Expand Up @@ -44,15 +45,31 @@ function parses(v: string): boolean {
* Returns true if `v` is a safe general-purpose URL for storing in site
* props (e.g. a link href, a button href).
*
* Allows: https:, http:, mailto:
* Rejects: javascript:, data:, ftp:, blob:, custom schemes, schemeless values,
* and malformed absolute URLs (`http://`, `https://exa mple.com`)
* Allows: https:, http:, mailto:, and every schemeless (relative) reference —
* `/a`, `./a`, `../a`, `a.html`, `#anchor`, `?q=1`, `//cdn/x` — plus
* values carrying `{source.field}` tokens the publisher interpolates
* (`/blog/{currentEntry.slug}`)
* Rejects: javascript:, data:, ftp:, blob:, custom schemes, malformed absolute
* URLs (`http://`, `https://exa mple.com`), and any value containing
* whitespace
*
* Relative references are accepted because `isSafeUrl()` — the enforcement gate
* this one is the input-side counterpart to — accepts every relative form, and
* internal links, in-page anchors and data-token hrefs are all schemeless by
* nature. Rejecting them here left those values unsavable in the editor even
* though the publisher would happily emit them.
*/
export function isValidUrl(v: string): boolean {
if (!v) return true
const scheme = urlScheme(v)
if (scheme !== 'https:' && scheme !== 'http:' && scheme !== 'mailto:') return false
return parses(v)
if (scheme !== null) {
if (scheme !== 'https:' && scheme !== 'http:' && scheme !== 'mailto:') return false
return parses(v)
}
// Schemeless — a relative reference. `new URL()` cannot parse one without a
// base, and there is no scheme to decide on, so the only well-formedness
// check left is whitespace, which is always a typo in a URL.
return !/\s/.test(v)
}

/**
Expand Down