Skip to content

Commit 2cb0c59

Browse files
authored
fix(richtext-lexical): urls being wrongly encoded by incomplete URL validation (#14557)
Fixes #14499 The issue was caused because our `validateUrl` function failed to catch URLs with query params as valid, added test suite for validateUrl as a function as well
1 parent 454d0d3 commit 2cb0c59

File tree

2 files changed

+181
-6
lines changed

2 files changed

+181
-6
lines changed

packages/richtext-lexical/src/lexical/utils/url.spec.ts

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { jest } from '@jest/globals'
2-
import { absoluteRegExp, relativeOrAnchorRegExp } from './url.js'
2+
import { absoluteRegExp, relativeOrAnchorRegExp, validateUrl } from './url.js'
33

44
describe('Lexical URL Regex Matchers', () => {
5-
describe('relative URLs', () => {
5+
describe('relativeOrAnchorRegExp', () => {
66
it('validation for links it should match', async () => {
77
const shouldMatch = [
88
'/path/to/resource',
@@ -13,6 +13,10 @@ describe('Lexical URL Regex Matchers', () => {
1313
'#anchor',
1414
'#section-title',
1515
'/path#fragment',
16+
'/page?id=123',
17+
'/page?id=123#section',
18+
'/search?q=test',
19+
'/?global=true',
1620
]
1721

1822
shouldMatch.forEach((testCase) => {
@@ -40,7 +44,7 @@ describe('Lexical URL Regex Matchers', () => {
4044
})
4145
})
4246

43-
describe('absolute URLs', () => {
47+
describe('absoluteRegExp', () => {
4448
it('validation for links it should match', async () => {
4549
const shouldMatch = [
4650
'http://example.com',
@@ -85,4 +89,158 @@ describe('Lexical URL Regex Matchers', () => {
8589
})
8690
})
8791
})
92+
93+
describe('validateUrl', () => {
94+
describe('absolute URLs', () => {
95+
it('should validate http and https URLs', () => {
96+
const validUrls = [
97+
'http://example.com',
98+
'https://example.com',
99+
'http://www.example.com',
100+
'https://sub.example.com/path/file',
101+
'http://example.com/resource',
102+
'https://example.com/resource?key=value',
103+
'http://example.com/resource#anchor',
104+
]
105+
106+
validUrls.forEach((url) => {
107+
expect(validateUrl(url)).toBe(true)
108+
})
109+
})
110+
111+
it('should validate other protocol URLs', () => {
112+
const validUrls = ['ftp://files.example.com', 'mailto:email@example.com', 'tel:+1234567890']
113+
114+
validUrls.forEach((url) => {
115+
expect(validateUrl(url)).toBe(true)
116+
})
117+
})
118+
119+
it('should validate www URLs without protocol', () => {
120+
const validUrls = [
121+
'www.example.com',
122+
'www.example.com/resource',
123+
'www.example.com/resource?query=1',
124+
'www.example.com#fragment',
125+
]
126+
127+
validUrls.forEach((url) => {
128+
expect(validateUrl(url)).toBe(true)
129+
})
130+
})
131+
})
132+
133+
describe('relative URLs', () => {
134+
it('should validate relative paths', () => {
135+
const validUrls = [
136+
'/path/to/resource',
137+
'/file-name.html',
138+
'/',
139+
'/dir/',
140+
'/path.with.dots/',
141+
'/path#fragment',
142+
]
143+
144+
validUrls.forEach((url) => {
145+
expect(validateUrl(url)).toBe(true)
146+
})
147+
})
148+
})
149+
150+
describe('anchor links', () => {
151+
it('should validate anchor links', () => {
152+
const validUrls = ['#anchor', '#section-title']
153+
154+
validUrls.forEach((url) => {
155+
expect(validateUrl(url)).toBe(true)
156+
})
157+
})
158+
})
159+
160+
describe('with query params', () => {
161+
it('should validate relative URLs with query parameters', () => {
162+
const validUrls = [
163+
'/page?id=123',
164+
'/search?q=test',
165+
'/products?category=electronics&sort=price',
166+
'/path?key=value&another=param',
167+
'/page?id=123&filter=active',
168+
'/?global=true',
169+
]
170+
171+
validUrls.forEach((url) => {
172+
expect(validateUrl(url)).toBe(true)
173+
})
174+
})
175+
176+
it('should validate absolute URLs with query parameters', () => {
177+
const validUrls = [
178+
'https://example.com?id=123',
179+
'http://example.com/page?key=value',
180+
'www.example.com?search=query',
181+
'https://example.com/path?a=1&b=2&c=3',
182+
]
183+
184+
validUrls.forEach((url) => {
185+
expect(validateUrl(url)).toBe(true)
186+
})
187+
})
188+
189+
it('should validate URLs with query parameters and anchors', () => {
190+
const validUrls = [
191+
'/page?id=123#section',
192+
'https://example.com?key=value#anchor',
193+
'/search?q=test#results',
194+
]
195+
196+
validUrls.forEach((url) => {
197+
expect(validateUrl(url)).toBe(true)
198+
})
199+
})
200+
})
201+
202+
describe('edge cases', () => {
203+
it('should handle the default https:// case', () => {
204+
expect(validateUrl('https://')).toBe(true)
205+
})
206+
207+
it('should return false for empty or invalid URLs', () => {
208+
const invalidUrls = [
209+
'',
210+
'not-a-url',
211+
'example.com',
212+
'relative/path',
213+
'file.html',
214+
'some#fragment',
215+
'http://',
216+
'http:/example.com',
217+
'http//example.com',
218+
]
219+
220+
invalidUrls.forEach((url) => {
221+
expect(validateUrl(url)).toBe(false)
222+
})
223+
})
224+
225+
it('should return false for URLs with spaces', () => {
226+
const invalidUrls = [
227+
'/path/with spaces',
228+
'http://example.com/ spaces',
229+
'https://example.com/path with spaces',
230+
]
231+
232+
invalidUrls.forEach((url) => {
233+
expect(validateUrl(url)).toBe(false)
234+
})
235+
})
236+
237+
it('should return false for malformed URLs', () => {
238+
const invalidUrls = ['://missing.scheme', 'ftp://example .com', 'http://example', '#', '/#']
239+
240+
invalidUrls.forEach((url) => {
241+
expect(validateUrl(url)).toBe(false)
242+
})
243+
})
244+
})
245+
})
88246
})

packages/richtext-lexical/src/lexical/utils/url.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ export const absoluteRegExp =
3535
* - /privacy-policy
3636
* - /privacy-policy#primary-terms
3737
* - #primary-terms
38+
* - /page?id=123
39+
* - /page?id=123#section
3840
* */
39-
export const relativeOrAnchorRegExp = /^(?:\/[\w\-./]*(?:#\w[\w-]*)?|#[\w\-]+)$/
41+
export const relativeOrAnchorRegExp = /^(?:\/[\w\-./]*(?:\?[-;&=%\w]*)?(?:#[\w-]+)?|#[\w\-]+)$/
4042

4143
/**
4244
* Prevents unreasonable URLs from being inserted into the editor.
@@ -55,11 +57,20 @@ export function validateUrlMinimal(url: string): boolean {
5557
export function validateUrl(url: string): boolean {
5658
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
5759
// Maybe show a dialog where they user can type the URL before inserting it.
58-
5960
if (!url) {
6061
return false
6162
}
6263

64+
// Reject URLs with spaces
65+
if (url.includes(' ')) {
66+
return false
67+
}
68+
69+
// Reject malformed protocol URLs (e.g., http:/example.com instead of http://example.com)
70+
if (/^[a-z][a-z\d+.-]*:\/[^/]/i.test(url)) {
71+
return false
72+
}
73+
6374
if (url === 'https://') {
6475
return true
6576
}
@@ -76,7 +87,13 @@ export function validateUrl(url: string): boolean {
7687

7788
// While this doesn't allow URLs starting with www (which is why we use the regex above), it does properly handle tel: URLs
7889
try {
79-
new URL(url)
90+
const urlObj = new URL(url)
91+
// For http/https/ftp protocols, require a proper domain with at least one dot (for TLD)
92+
if (['ftp:', 'http:', 'https:'].includes(urlObj.protocol)) {
93+
if (!urlObj.hostname.includes('.')) {
94+
return false
95+
}
96+
}
8097
return true
8198
} catch {
8299
/* empty */

0 commit comments

Comments
 (0)