feat: add a utility function to check for localhost. (#2454)
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']);
|
||||
const IPV4_LOOPBACK_REGEX = /^127(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/;
|
||||
|
||||
/**
|
||||
* Determines whether a given URL string points to a localhost / loopback address.
|
||||
*
|
||||
* @param url The URL string to test.
|
||||
* @returns true if the URL is a valid network URL pointing to localhost or a loopback IP.
|
||||
*/
|
||||
export function isLocalhost(url?: string): boolean {
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, '');
|
||||
|
||||
// 1. Check localhost and RFC 6761 *.localhost subdomains
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Check IPv6 loopback ([::1] as returned by URL.hostname, or ::1)
|
||||
if (hostname === '[::1]' || hostname === '::1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Check IPv4 loopback (127.0.0.0/8)
|
||||
if (IPV4_LOOPBACK_REGEX.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import {isLocalhost} from '../../src/utils/url.js';
|
||||
|
||||
describe('isLocalhost', () => {
|
||||
it('should return true for valid localhost and loopback URLs', () => {
|
||||
assert.strictEqual(isLocalhost('http://localhost'), true);
|
||||
assert.strictEqual(isLocalhost('http://localhost:9222'), true);
|
||||
assert.strictEqual(
|
||||
isLocalhost('https://localhost:8080/path?query=1'),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(isLocalhost('http://subdomain.localhost:3000'), true);
|
||||
assert.strictEqual(isLocalhost('https://app.dev.localhost'), true);
|
||||
assert.strictEqual(isLocalhost('http://127.0.0.1:9222'), true);
|
||||
assert.strictEqual(
|
||||
isLocalhost('ws://127.0.0.1:9222/devtools/browser/123'),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(isLocalhost('wss://127.0.0.2'), true);
|
||||
assert.strictEqual(isLocalhost('http://[::1]:8080'), true);
|
||||
assert.strictEqual(isLocalhost('ws://[::1]:9222'), true);
|
||||
});
|
||||
|
||||
it('should return true for alternative IPv4 representations', () => {
|
||||
assert.strictEqual(isLocalhost('http://127.1'), true); // short-form
|
||||
assert.strictEqual(isLocalhost('http://2130706433'), true); // decimal integer
|
||||
assert.strictEqual(isLocalhost('http://0177.0.0.1'), true); // octal
|
||||
assert.strictEqual(isLocalhost('http://0x7f.0.0.1'), true); // hex
|
||||
});
|
||||
|
||||
it('should return true for full 127.0.0.0/8 loopback range', () => {
|
||||
assert.strictEqual(isLocalhost('http://127.0.0.0'), true);
|
||||
assert.strictEqual(isLocalhost('http://127.255.255.255'), true);
|
||||
});
|
||||
|
||||
it('should return true for FQDN hostnames with trailing root dot', () => {
|
||||
assert.strictEqual(isLocalhost('http://localhost.'), true);
|
||||
assert.strictEqual(isLocalhost('http://subdomain.localhost.'), true);
|
||||
});
|
||||
|
||||
it('should return true for uncompressed IPv6 loopback', () => {
|
||||
assert.strictEqual(isLocalhost('http://[0:0:0:0:0:0:0:1]:8080'), true);
|
||||
});
|
||||
|
||||
it('should return true for URLs with user credentials', () => {
|
||||
assert.strictEqual(isLocalhost('http://user:pass@localhost:8080'), true);
|
||||
});
|
||||
|
||||
it('should return false for non-localhost hostnames and IPs', () => {
|
||||
assert.strictEqual(isLocalhost('http://localhost.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://localhost.evil.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://evillocalhost'), false);
|
||||
assert.strictEqual(isLocalhost('http://localhost-evil.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://localhost@evil.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://128.0.0.1'), false);
|
||||
assert.strictEqual(isLocalhost('http://192.168.1.1'), false);
|
||||
assert.strictEqual(isLocalhost('http://127.0.0.256'), false); // out of range octet
|
||||
assert.strictEqual(isLocalhost('http://127.0.0.1.example.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://0.0.0.0:8080'), false);
|
||||
assert.strictEqual(isLocalhost('https://example.com'), false);
|
||||
assert.strictEqual(isLocalhost('http://[::2]:8080'), false);
|
||||
assert.strictEqual(isLocalhost('http://[2001:db8::1]:8080'), false);
|
||||
});
|
||||
|
||||
it('should return false for non-network schemes', () => {
|
||||
assert.strictEqual(isLocalhost('about:blank'), false);
|
||||
assert.strictEqual(isLocalhost('chrome://inspect'), false);
|
||||
assert.strictEqual(isLocalhost('chrome://version'), false);
|
||||
assert.strictEqual(isLocalhost('file:///etc/hosts'), false);
|
||||
assert.strictEqual(isLocalhost('ftp://localhost'), false);
|
||||
assert.strictEqual(isLocalhost('data:text/html,localhost'), false);
|
||||
assert.strictEqual(isLocalhost('javascript:alert(1)'), false);
|
||||
});
|
||||
|
||||
it('should return false for invalid, empty, or undefined URLs', () => {
|
||||
assert.strictEqual(isLocalhost(undefined), false);
|
||||
assert.strictEqual(isLocalhost(''), false);
|
||||
assert.strictEqual(isLocalhost(' '), false);
|
||||
assert.strictEqual(isLocalhost('not a url'), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user