feat: add canonical hostname normalization
This commit is contained in:
parent
e1a6b2af64
commit
39052ffe39
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.24.4
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
|
golang.org/x/net v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -32,7 +33,6 @@ require (
|
||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // 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/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue