From 39052ffe39b9f5bcd62e26325bff3bc77327bff3 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 12 Jul 2026 17:16:53 +0800 Subject: [PATCH] feat: add canonical hostname normalization --- go.mod | 2 +- internal/core/hostname/hostname.go | 123 ++++++++++++++++++ internal/core/hostname/hostname_test.go | 50 +++++++ .../testdata/hostname-normalization-v1.json | 34 +++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 internal/core/hostname/hostname.go create mode 100644 internal/core/hostname/hostname_test.go create mode 100644 internal/core/hostname/testdata/hostname-normalization-v1.json diff --git a/go.mod b/go.mod index 8dd2d1a..17c71c1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/google/uuid v1.6.0 github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/net v0.35.0 ) require ( @@ -32,7 +33,6 @@ require ( github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect ) diff --git a/internal/core/hostname/hostname.go b/internal/core/hostname/hostname.go new file mode 100644 index 0000000..9b596db --- /dev/null +++ b/internal/core/hostname/hostname.go @@ -0,0 +1,123 @@ +package hostname + +import ( + "net/netip" + "net/url" + "strings" + "unicode" + + "golang.org/x/net/idna" +) + +const ( + maxDNSHostnameLength = 253 + maxDNSLabelLength = 63 +) + +// NormalizeHostnameV1 returns the canonical A-label hostname used by domain +// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal, +// localhost, or a single-label internal hostname. Invalid input returns "". +func NormalizeHostnameV1(input string) string { + value := strings.TrimSpace(input) + if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") { + return "" + } + + if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") { + if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") { + return "" + } + return normalizeIPv6(value[1 : len(value)-1]) + } + if strings.Contains(value, ":") { + return "" + } + + value = strings.TrimSuffix(value, ".") + if value == "" || strings.HasSuffix(value, ".") { + return "" + } + if isNumericHostname(value) { + return normalizeIPv4(value) + } + return normalizeDNS(value) +} + +// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL. +// Ports, paths, credentials, and fragments are intentionally not preserved. +func NormalizeURLHostnameV1(input string) string { + value := strings.TrimSpace(input) + if value == "" { + return "" + } + parsed, err := url.ParseRequestURI(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "" + } + host := parsed.Hostname() + if host == "" { + return "" + } + if address, err := netip.ParseAddr(host); err == nil && address.Is6() { + return address.String() + } + return NormalizeHostnameV1(host) +} + +func normalizeIPv4(value string) string { + address, err := netip.ParseAddr(value) + if err != nil || !address.Is4() || address.String() != value { + return "" + } + return address.String() +} + +func normalizeIPv6(value string) string { + address, err := netip.ParseAddr(value) + if err != nil || !address.Is6() { + return "" + } + return address.String() +} + +func normalizeDNS(value string) string { + ascii, err := idna.Lookup.ToASCII(value) + if err != nil { + return "" + } + ascii = strings.ToLower(strings.TrimSuffix(ascii, ".")) + if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength { + return "" + } + for _, label := range strings.Split(ascii, ".") { + if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) { + return "" + } + } + return ascii +} + +func isNumericHostname(value string) bool { + for _, char := range value { + if (char < '0' || char > '9') && char != '.' { + return false + } + } + return true +} + +func isDNSLabel(label string) bool { + if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) { + return false + } + for _, char := range label { + if !isASCIIAlphaNum(byte(char)) && char != '-' { + return false + } + } + return true +} + +func isASCIIAlphaNum(char byte) bool { + return char >= 'a' && char <= 'z' || char >= '0' && char <= '9' +} diff --git a/internal/core/hostname/hostname_test.go b/internal/core/hostname/hostname_test.go new file mode 100644 index 0000000..1c872b9 --- /dev/null +++ b/internal/core/hostname/hostname_test.go @@ -0,0 +1,50 @@ +package hostname + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type normalizationVector struct { + Input string `json:"input"` + Output string `json:"output"` +} + +type normalizationVectors struct { + Bare []normalizationVector `json:"bare"` + URL []normalizationVector `json:"url"` +} + +func TestNormalizeHostnameV1Vectors(t *testing.T) { + vectors := loadVectors(t) + for _, vector := range vectors.Bare { + if got := NormalizeHostnameV1(vector.Input); got != vector.Output { + t.Errorf("NormalizeHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output) + } + } +} + +func TestNormalizeURLHostnameV1Vectors(t *testing.T) { + vectors := loadVectors(t) + for _, vector := range vectors.URL { + if got := NormalizeURLHostnameV1(vector.Input); got != vector.Output { + t.Errorf("NormalizeURLHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output) + } + } +} + +func loadVectors(t *testing.T) normalizationVectors { + t.Helper() + path := filepath.Join("testdata", "hostname-normalization-v1.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var vectors normalizationVectors + if err := json.Unmarshal(data, &vectors); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return vectors +} diff --git a/internal/core/hostname/testdata/hostname-normalization-v1.json b/internal/core/hostname/testdata/hostname-normalization-v1.json new file mode 100644 index 0000000..92724c8 --- /dev/null +++ b/internal/core/hostname/testdata/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": "" } + ] +}