From e67a7e4ff7c6a549f0e8cda22ca1a7acdfc11b76 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 12 Jul 2026 17:15:35 +0800 Subject: [PATCH] feat: normalize browser hostnames consistently --- firefox/manifest.json | 2 +- package.json | 2 +- scripts/build-extension.js | 3 +- scripts/test-hostname.js | 18 ++++++ scripts/test-protocol.js | 1 + shared/hostname-normalization-v1.json | 34 +++++++++++ shared/hostname.js | 86 +++++++++++++++++++++++++++ shared/protocol.js | 8 +-- 8 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 scripts/test-hostname.js create mode 100644 shared/hostname-normalization-v1.json create mode 100644 shared/hostname.js diff --git a/firefox/manifest.json b/firefox/manifest.json index fb826b8..0ef91fc 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -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", diff --git a/package.json b/package.json index b0557d2..66ff076 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/build-extension.js b/scripts/build-extension.js index a1b8616..11b73bb 100755 --- a/scripts/build-extension.js +++ b/scripts/build-extension.js @@ -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); diff --git a/scripts/test-hostname.js b/scripts/test-hostname.js new file mode 100644 index 0000000..b9be840 --- /dev/null +++ b/scripts/test-hostname.js @@ -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'); diff --git a/scripts/test-protocol.js b/scripts/test-protocol.js index 81a9ca0..5f78042 100755 --- a/scripts/test-protocol.js +++ b/scripts/test-protocol.js @@ -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'); diff --git a/shared/hostname-normalization-v1.json b/shared/hostname-normalization-v1.json new file mode 100644 index 0000000..92724c8 --- /dev/null +++ b/shared/hostname-normalization-v1.json @@ -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": "" } + ] +} diff --git a/shared/hostname.js b/shared/hostname.js new file mode 100644 index 0000000..b035c1e --- /dev/null +++ b/shared/hostname.js @@ -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); diff --git a/shared/protocol.js b/shared/protocol.js index dc01f95..7892429 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -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) {