Skip to content

Commit 4521994

Browse files
committed
feat(isURL): rewrite isURL with native URL constructor
1 parent 6aed799 commit 4521994

1 file changed

Lines changed: 227 additions & 69 deletions

File tree

src/lib/isURL.js

Lines changed: 227 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,6 @@ const default_url_options = {
5050
max_allowed_length: 2084,
5151
};
5252

53-
const wrapped_ipv6 = /^\[([^\]]+)\](?::([0-9]+))?$/;
54-
5553
export default function isURL(url, options) {
5654
assertString(url);
5755
if (!url || /[\s<>]/.test(url)) {
@@ -60,7 +58,24 @@ export default function isURL(url, options) {
6058
if (url.indexOf('mailto:') === 0) {
6159
return false;
6260
}
63-
options = merge(options, default_url_options);
61+
62+
// Security check: Reject URLs with Unicode characters that could be dangerous protocol spoofs
63+
// Convert full-width Unicode to ASCII and check for dangerous protocols
64+
const normalizedUrl = url.replace(/[\uFF00-\uFFEF]/g, (char) => {
65+
const code = char.charCodeAt(0);
66+
if (code >= 0xFF01 && code <= 0xFF5E) {
67+
return String.fromCharCode(code - 0xFEE0);
68+
}
69+
return char;
70+
});
71+
72+
/* eslint-disable no-script-url */
73+
const dangerousProtocolPrefixes = ['javascript:', 'data:', 'vbscript:'];
74+
/* eslint-enable no-script-url */
75+
if (dangerousProtocolPrefixes.some(protocol =>
76+
normalizedUrl.toLowerCase().startsWith(protocol))) {
77+
return false;
78+
} options = merge(options, default_url_options);
6479

6580
if (options.validate_length && url.length > options.max_allowed_length) {
6681
return false;
@@ -70,110 +85,253 @@ export default function isURL(url, options) {
7085
return false;
7186
}
7287

73-
if (!options.allow_query_components && (includes(url, '?') || includes(url, '&'))) {
88+
if (
89+
!options.allow_query_components &&
90+
(includes(url, '?') || includes(url, '&'))
91+
) {
7492
return false;
7593
}
7694

77-
let protocol, auth, host, hostname, port, port_str, split, ipv6;
78-
let has_protocol = false;
95+
let originalUrl = url;
96+
let hasProtocol = false;
97+
let isProtocolRelative = false;
7998

80-
split = url.split('#');
81-
url = split.shift();
99+
// Check for multiple slashes like ////foobar.com or http:////foobar.com
100+
// But allow file:/// which is a valid file URL pattern
101+
if (
102+
url.startsWith('///') ||
103+
(originalUrl.match(/:\/\/\/\/+/) && !originalUrl.startsWith('file:///'))
104+
) {
105+
return false;
106+
}
82107

83-
split = url.split('?');
84-
url = split.shift();
108+
// Check for protocol-relative URLs (must start with exactly //)
109+
if (url.startsWith('//') && !url.startsWith('///')) {
110+
if (!options.allow_protocol_relative_urls) {
111+
return false;
112+
}
113+
isProtocolRelative = true;
114+
hasProtocol = true;
115+
url = `http:${url}`; // Temporarily add protocol for parsing
116+
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
117+
// Only check for auth-like patterns if there's no :// in the URL (not a real protocol)
118+
if (!originalUrl.includes('://')) {
119+
// Special case: check if this looks like auth info rather than a protocol
120+
// Pattern: word:something@domain (but not common protocols)
121+
const authLikeMatch = originalUrl.match(/^([^:/@]+):([^@]*@[^/]+)/);
122+
if (authLikeMatch) {
123+
const possibleProtocol = authLikeMatch[1].toLowerCase();
124+
125+
// Normalize Unicode full-width characters to ASCII for security check
126+
const normalizedProtocol = possibleProtocol.replace(/[\uFF00-\uFFEF]/g, (char) => {
127+
const code = char.charCodeAt(0);
128+
// Convert full-width ASCII to regular ASCII
129+
if (code >= 0xFF01 && code <= 0xFF5E) {
130+
return String.fromCharCode(code - 0xFEE0);
131+
}
132+
return char;
133+
});
134+
135+
const knownDangerousProtocols = ['javascript', 'data', 'vbscript'];
85136

86-
split = url.split('://');
87-
if (split.length > 1) {
88-
has_protocol = true;
89-
protocol = split.shift().toLowerCase();
90-
if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) {
137+
if (!knownDangerousProtocols.includes(possibleProtocol) &&
138+
!knownDangerousProtocols.includes(normalizedProtocol)) {
139+
// This looks like auth info, treat as no protocol
140+
hasProtocol = false; // Important: mark as no protocol since we're adding one
141+
url = `http://${url}`;
142+
} else {
143+
hasProtocol = true;
144+
// This is a dangerous protocol in auth component (CVE-2025-56200)
145+
return false;
146+
}
147+
} else {
148+
hasProtocol = true;
149+
}
150+
} else {
151+
hasProtocol = true;
152+
}
153+
} else {
154+
// Single slash should not be treated as protocol-relative
155+
if (url.startsWith('/') && !url.startsWith('//')) {
91156
return false;
92157
}
93-
} else if (options.require_protocol) {
94-
return false;
95-
} else if (url.slice(0, 2) === '//') {
96-
if (!options.allow_protocol_relative_urls) {
158+
159+
// No protocol, add a temporary one for parsing
160+
url = `http://${url}`;
161+
}
162+
163+
let parsedUrl;
164+
165+
// Special handling for database URLs like postgres://user:pw@/test
166+
if (
167+
originalUrl.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^@\/]+@\//) &&
168+
!options.require_host
169+
) {
170+
// This is a database URL with empty hostname but auth and path
171+
try {
172+
// Replace @/ with @localhost/ temporarily for parsing
173+
const tempUrl = url.replace('@/', '@localhost/');
174+
parsedUrl = new URL(tempUrl);
175+
// Clear the hostname since it was fake
176+
Object.defineProperty(parsedUrl, 'hostname', {
177+
value: '',
178+
writable: false,
179+
});
180+
Object.defineProperty(parsedUrl, 'host', { value: '', writable: false });
181+
} catch (e) {
182+
return false;
183+
}
184+
} else {
185+
// Use native URL constructor for parsing
186+
try {
187+
parsedUrl = new URL(url);
188+
} catch (e) {
97189
return false;
98190
}
99-
has_protocol = true;
100-
split[0] = url.slice(2);
101191
}
102-
url = split.join('://');
103192

104-
if (url === '') {
193+
// Validate protocol
194+
const protocol = parsedUrl.protocol.slice(0, -1); // Remove trailing ':'
195+
if (
196+
hasProtocol &&
197+
options.require_valid_protocol &&
198+
!options.protocols.includes(protocol)
199+
) {
200+
return false;
201+
}
202+
if (!hasProtocol && options.require_protocol) {
203+
return false;
204+
}
205+
if (isProtocolRelative && options.require_protocol) {
105206
return false;
106207
}
107208

108-
split = url.split('/');
109-
url = split.shift();
209+
// Handle special case for URLs ending with just protocol:// (should always fail)
210+
// But allow URLs like file:/// that have paths
211+
if (
212+
!parsedUrl.hostname &&
213+
hasProtocol &&
214+
originalUrl.endsWith('://') &&
215+
(!parsedUrl.pathname || parsedUrl.pathname === '/')
216+
) {
217+
return false;
218+
}
110219

111-
if (url === '' && !options.require_host) {
220+
// Validate host presence
221+
if (!parsedUrl.hostname && options.require_host) {
222+
return false;
223+
}
224+
if (!parsedUrl.hostname && !options.require_host) {
112225
return true;
113226
}
114227

115-
split = url.split('@');
116-
if (split.length > 1) {
117-
if (options.disallow_auth) {
118-
return false;
119-
}
120-
if (split[0] === '') {
121-
return false;
122-
}
123-
auth = split.shift();
124-
if (!has_protocol && auth.indexOf(':') !== -1) {
125-
return false;
126-
}
127-
if (auth.indexOf(':') >= 0 && auth.split(':').length > 2) {
128-
return false;
129-
}
130-
const [user, password] = auth.split(':');
131-
if (user === '' && password === '') {
228+
// Validate port
229+
if (options.require_port && !parsedUrl.port) {
230+
return false;
231+
}
232+
if (parsedUrl.port) {
233+
const port = parseInt(parsedUrl.port, 10);
234+
if (port <= 0 || port > 65535) {
132235
return false;
133236
}
134237
}
135-
hostname = split.join('@');
136238

137-
port_str = null;
138-
ipv6 = null;
139-
const ipv6_match = hostname.match(wrapped_ipv6);
140-
if (ipv6_match) {
141-
host = '';
142-
ipv6 = ipv6_match[1];
143-
port_str = ipv6_match[2] || null;
144-
} else {
145-
split = hostname.split(':');
146-
host = split.shift();
147-
if (split.length) {
148-
port_str = split.join(':');
239+
// Validate authentication
240+
if (options.disallow_auth && (parsedUrl.username || parsedUrl.password)) {
241+
return false;
242+
}
243+
244+
// Additional auth validation for security (multiple colons check)
245+
if (parsedUrl.username !== '' || parsedUrl.password !== '') {
246+
// Check the original URL for multiple colons in auth part
247+
const authMatch = originalUrl.match(/@([^/]+)/);
248+
if (authMatch) {
249+
const beforeAuth = originalUrl.substring(
250+
0,
251+
originalUrl.indexOf(authMatch[0])
252+
);
253+
const authPart = beforeAuth.split('://').pop() || beforeAuth;
254+
if (authPart.split(':').length > 2) {
255+
return false;
256+
}
149257
}
150258
}
151259

152-
if (port_str !== null && port_str.length > 0) {
153-
port = parseInt(port_str, 10);
154-
if (!/^[0-9]+$/.test(port_str) || port <= 0 || port > 65535) {
260+
// Reject URLs with empty auth components like @example.com, :@example.com, or http://@example.com
261+
const emptyAuthMatch = originalUrl.match(/^(@|:@|\/\/@[^/]|\/\/:@)/);
262+
if (emptyAuthMatch) {
263+
return false;
264+
}
265+
266+
// Also check for empty username in parsed URL (handles http://@example.com)
267+
// But allow empty username if there's a password (http://:pass@example.com)
268+
if (
269+
parsedUrl.username === '' &&
270+
parsedUrl.password === '' &&
271+
originalUrl.includes('@') &&
272+
!originalUrl.match(/^[^:]+:@/)
273+
) {
274+
return false;
275+
}
276+
277+
// Security check: Reject URLs where username looks like a domain (phishing protection)
278+
// e.g., http://evil-site.com@example.com should be rejected
279+
if (parsedUrl.username && parsedUrl.username.includes('.')) {
280+
// Check if username looks like a domain (has common TLD patterns)
281+
const usernamePattern = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
282+
if (usernamePattern.test(parsedUrl.username)) {
155283
return false;
156284
}
157-
} else if (options.require_port) {
158-
return false;
159285
}
160286

161-
if (options.host_whitelist) {
162-
return checkHost(host, options.host_whitelist);
287+
let hostname = parsedUrl.hostname;
288+
289+
// Special handling for URLs with empty hostnames but paths (like postgres://user:pw@/test)
290+
if (!hostname && originalUrl.includes('@/') && hasProtocol) {
291+
// This is likely a database URL with empty hostname but a path
292+
return !options.require_host;
163293
}
164294

165-
if (host === '' && !options.require_host) {
166-
return true;
295+
// Handle IPv6 addresses
296+
let isIPv6 = false;
297+
if (hostname && hostname.startsWith('[') && hostname.endsWith(']')) {
298+
const ipv6Address = hostname.slice(1, -1);
299+
if (!isIP(ipv6Address, 6)) {
300+
return false;
301+
}
302+
isIPv6 = true;
303+
hostname = ipv6Address;
167304
}
168305

169-
if (!isIP(host) && !isFQDN(host, options) && (!ipv6 || !isIP(ipv6, 6))) {
306+
// Validate host whitelist/blacklist
307+
if (hostname && options.host_whitelist) {
308+
return checkHost(hostname, options.host_whitelist);
309+
}
310+
311+
if (
312+
hostname &&
313+
options.host_blacklist &&
314+
checkHost(hostname, options.host_blacklist)
315+
) {
170316
return false;
171317
}
172318

173-
host = host || ipv6;
319+
// Validate host format
320+
if (hostname && !isIPv6) {
321+
if (isIP(hostname)) {
322+
// IPv4 address is valid
323+
} else {
324+
// Validate as FQDN
325+
const fqdnOptions = {
326+
require_tld: options.require_tld,
327+
allow_underscores: options.allow_underscores,
328+
allow_trailing_dot: options.allow_trailing_dot,
329+
};
174330

175-
if (options.host_blacklist && checkHost(host, options.host_blacklist)) {
176-
return false;
331+
if (!isFQDN(hostname, fqdnOptions)) {
332+
return false;
333+
}
334+
}
177335
}
178336

179337
return true;

0 commit comments

Comments
 (0)