feat: normalize browser hostnames consistently

This commit is contained in:
2026-07-12 17:15:35 +08:00
parent d8bd1d1828
commit e67a7e4ff7
8 changed files with 146 additions and 8 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.mirv.top/verstak/verstak-sdk/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}
+86
View File
@@ -0,0 +1,86 @@
(function (root) {
'use strict';
var MAX_DNS_HOSTNAME_LENGTH = 253;
var MAX_DNS_LABEL_LENGTH = 63;
function trimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeIPv4(value) {
var parts = value.split('.');
if (parts.length !== 4) return '';
for (var i = 0; i < parts.length; i += 1) {
if (!/^(0|[1-9][0-9]{0,2})$/.test(parts[i])) return '';
if (Number(parts[i]) > 255) return '';
}
return parts.join('.');
}
function normalizeIPv6(value) {
try {
var parsed = new URL('http://[' + value + ']');
var hostname = parsed.hostname;
if (hostname[0] !== '[' || hostname[hostname.length - 1] !== ']') return '';
return hostname.slice(1, -1).toLowerCase();
} catch (_) {
return '';
}
}
function normalizeDNS(value) {
var ascii;
try {
ascii = new URL('http://' + value).hostname.toLowerCase();
} catch (_) {
return '';
}
if (!ascii || ascii[0] === '[' || ascii.indexOf(':') !== -1) return '';
if (ascii[ascii.length - 1] === '.') ascii = ascii.slice(0, -1);
if (!ascii || ascii[ascii.length - 1] === '.' || ascii.length > MAX_DNS_HOSTNAME_LENGTH) return '';
var labels = ascii.split('.');
for (var i = 0; i < labels.length; i += 1) {
if (labels[i].length === 0 || labels[i].length > MAX_DNS_LABEL_LENGTH) return '';
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labels[i])) return '';
}
return ascii;
}
function normalizeHostnameV1(input) {
var value = trimmedString(input);
if (!value || /[\s\\/?#@]/.test(value)) return '';
if (value[0] === '[' || value[value.length - 1] === ']') {
if (value[0] !== '[' || value[value.length - 1] !== ']') return '';
return normalizeIPv6(value.slice(1, -1));
}
if (value.indexOf(':') !== -1) return '';
if (value[value.length - 1] === '.') value = value.slice(0, -1);
if (!value || value[value.length - 1] === '.') return '';
if (/^[0-9.]+$/.test(value)) return normalizeIPv4(value);
return normalizeDNS(value);
}
function normalizeURLHostnameV1(input) {
var value = trimmedString(input);
if (!value) return '';
try {
var url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
return normalizeHostnameV1(url.hostname);
} catch (_) {
return '';
}
}
var api = {
normalizeHostnameV1: normalizeHostnameV1,
normalizeURLHostnameV1: normalizeURLHostnameV1
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
+3 -5
View File
@@ -29,11 +29,9 @@
}
function hostname(url) {
try {
return new URL(url).hostname;
} catch (_) {
return '';
}
return root.VerstakBrowser.normalizeURLHostnameV1
? root.VerstakBrowser.normalizeURLHostnameV1(url)
: '';
}
function buildCapture(input) {