feat: normalize browser hostnames consistently

This commit is contained in:
mirivlad 2026-07-12 17:15:35 +08:00
parent d8bd1d1828
commit e67a7e4ff7
8 changed files with 146 additions and 8 deletions

View File

@ -17,7 +17,7 @@
},
"permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"],
"background": {
"scripts": ["protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
"scripts": ["hostname.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
},
"browser_action": {
"default_popup": "popup/popup.html",

View File

@ -5,7 +5,7 @@
"description": "Verstak browser capture extension for Chromium and Firefox",
"scripts": {
"build": "node scripts/build-extension.js",
"test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
"test": "node scripts/test-hostname.js && node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
"sign:firefox": "./scripts/sign-firefox-xpi.sh",
"release:firefox": "./scripts/release-firefox-xpi.sh"
},

View File

@ -51,6 +51,7 @@ const chromiumDist = path.join(dist, 'chromium');
mkdir(chromiumDist);
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
concat([
path.join(shared, 'hostname.js'),
path.join(shared, 'protocol.js'),
path.join(shared, 'api.js'),
path.join(shared, 'queue.js'),
@ -64,7 +65,7 @@ copyLocalization(chromiumDist);
const firefoxDist = path.join(dist, 'firefox');
mkdir(firefoxDist);
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
for (const name of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
for (const name of ['hostname.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
copy(path.join(shared, name), path.join(firefoxDist, name));
}
copyPopup(firefoxDist);

18
scripts/test-hostname.js Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const hostname = require('../shared/hostname');
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, '../shared/hostname-normalization-v1.json'), 'utf8'));
for (const vector of vectors.bare) {
assert.equal(hostname.normalizeHostnameV1(vector.input), vector.output, vector.input);
}
for (const vector of vectors.url) {
assert.equal(hostname.normalizeURLHostnameV1(vector.input), vector.output, vector.input);
}
console.log('browser hostname normalization tests passed');

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node
const assert = require('assert');
require('../shared/hostname');
const protocol = require('../shared/protocol');
require('../shared/api');
const queueApi = require('../shared/queue');

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
shared/hostname.js Normal file
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);

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) {