diff --git a/cmd/all.go b/cmd/all.go index 0bbd17b..77ff478 100644 --- a/cmd/all.go +++ b/cmd/all.go @@ -30,7 +30,10 @@ var allCmd = &cobra.Command{ Short: "Run all lookups combined (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - input := normalizeDomain(args[0]) + input, err := normalizeDomain(args[0]) + if err != nil { + return err + } result := allResult{} isIP := net.ParseIP(input) != nil diff --git a/cmd/dns.go b/cmd/dns.go index 0806919..a6943e8 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -3,9 +3,9 @@ package cmd import ( "encoding/json" "fmt" - "strings" "github.com/retlehs/quien/internal/dns" + "github.com/retlehs/quien/internal/resolver" "github.com/retlehs/quien/internal/retry" "github.com/spf13/cobra" ) @@ -15,7 +15,10 @@ var dnsCmd = &cobra.Command{ Short: "DNS record lookup (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } records, err := retry.Do(func() (*dns.Records, error) { return dns.Lookup(domain) }) @@ -30,8 +33,8 @@ func init() { rootCmd.AddCommand(dnsCmd) } -func normalizeDomain(s string) string { - return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") +func normalizeDomain(s string) (string, error) { + return resolver.NormalizeDomain(s) } func printJSON(v any) error { diff --git a/cmd/http.go b/cmd/http.go index ee93a35..f995275 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -13,7 +13,10 @@ var httpCmd = &cobra.Command{ Short: "HTTP header and redirect lookup (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } result, err := retry.Do(func() (*httpinfo.Result, error) { return httpinfo.Lookup(domain) }) diff --git a/cmd/mail.go b/cmd/mail.go index eaacf84..b3858b3 100644 --- a/cmd/mail.go +++ b/cmd/mail.go @@ -13,7 +13,10 @@ var mailCmd = &cobra.Command{ Short: "Mail configuration lookup — MX, SPF, DMARC, DKIM, BIMI (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } records, err := retry.Do(func() (*mail.Records, error) { return mail.Lookup(domain) }) diff --git a/cmd/root.go b/cmd/root.go index 34ccf4d..67da673 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,7 +70,10 @@ var rootCmd = &cobra.Command{ return runLookup(input, isIP) } - input := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args[0])), ".") + input, err := normalizeDomain(args[0]) + if err != nil { + return err + } isIP := net.ParseIP(input) != nil @@ -97,6 +100,11 @@ var rootCmd = &cobra.Command{ func runLookup(input string, isIP bool) error { if !isIP { + normalized, err := normalizeDomain(input) + if err != nil { + return err + } + input = normalized if _, err := resolver.RegistrableDomain(input); err != nil { return err } diff --git a/cmd/seo.go b/cmd/seo.go index 827684f..d234418 100644 --- a/cmd/seo.go +++ b/cmd/seo.go @@ -13,7 +13,10 @@ var seoCmd = &cobra.Command{ Short: "SEO and Core Web Vitals analysis (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } result, err := retry.Do(func() (*seo.Result, error) { return seo.Analyze(domain) }) diff --git a/cmd/stack.go b/cmd/stack.go index deea7c7..8fa2ca9 100644 --- a/cmd/stack.go +++ b/cmd/stack.go @@ -13,7 +13,10 @@ var stackCmd = &cobra.Command{ Short: "Detect technology stack — CMS, frameworks, libraries (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } result, err := retry.Do(func() (*stack.Result, error) { return stack.Detect(domain) }) diff --git a/cmd/tls.go b/cmd/tls.go index cd96318..ca889d1 100644 --- a/cmd/tls.go +++ b/cmd/tls.go @@ -14,7 +14,10 @@ var tlsCmd = &cobra.Command{ Short: "SSL/TLS certificate lookup (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - domain := normalizeDomain(args[0]) + domain, err := normalizeDomain(args[0]) + if err != nil { + return err + } cert, err := retry.Do(func() (*tlsinfo.CertInfo, error) { return tlsinfo.Lookup(domain) }) diff --git a/cmd/whois.go b/cmd/whois.go index ff24c18..2f8b0e3 100644 --- a/cmd/whois.go +++ b/cmd/whois.go @@ -13,7 +13,10 @@ var whoisCmd = &cobra.Command{ Short: "WHOIS/RDAP registration lookup (JSON output)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - input := normalizeDomain(args[0]) + input, err := normalizeDomain(args[0]) + if err != nil { + return err + } if net.ParseIP(input) != nil { info, err := resolver.LookupIP(input) if err != nil { diff --git a/go.mod b/go.mod index 08c9b9e..c63e12e 100644 --- a/go.mod +++ b/go.mod @@ -32,5 +32,6 @@ require ( golang.org/x/mod v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index bc8fc76..b7a7746 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/resolver/normalize.go b/internal/resolver/normalize.go index 28e37af..694ea9f 100644 --- a/internal/resolver/normalize.go +++ b/internal/resolver/normalize.go @@ -2,11 +2,40 @@ package resolver import ( "fmt" + "net" "strings" + "golang.org/x/net/idna" "golang.org/x/net/publicsuffix" ) +// idnaProfile parses and validates domains under the IDNA2008 "lookup" rules, +// mapping case/width and enforcing the Bidi rule before converting to ASCII. +var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.Transitional(false)) + +// toASCII converts a domain to its ASCII (punycode) form, returning an error +// for input that isn't a valid domain under IDNA lookup rules. +func toASCII(s string) (string, error) { + return idnaProfile.ToASCII(s) +} + +// NormalizeDomain trims surrounding whitespace and a trailing dot from s and +// returns its lowercased ASCII (punycode) form. IP literals pass through +// unchanged — they aren't domains and IDNA rejects the colons in IPv6 — while +// any other input that isn't a valid domain under IDNA lookup rules yields an +// error so callers can reject it instead of dispatching a doomed lookup. +func NormalizeDomain(s string) (string, error) { + s = strings.TrimSuffix(strings.TrimSpace(s), ".") + if net.ParseIP(s) != nil { + return strings.ToLower(s), nil + } + ascii, err := toASCII(s) + if err != nil { + return "", fmt.Errorf("%q is not a valid domain", s) + } + return ascii, nil +} + // RegistrableDomain returns the effective TLD+1 for s — the registrable // domain that a WHOIS/RDAP registry will actually answer queries for. // For "mail.google.com" it returns "google.com"; for "sub.example.co.jp" it @@ -16,6 +45,11 @@ import ( // "co.jp", anything without a dot) return an error so callers can reject them // cleanly instead of dispatching a doomed WHOIS query. func RegistrableDomain(s string) (string, error) { + ascii, err := toASCII(s) + if err != nil { + return "", fmt.Errorf("%q is not a valid domain", s) + } + s = ascii if len(s) < 3 || len(s) > 253 { return "", fmt.Errorf("%q is not a valid domain", s) } diff --git a/internal/resolver/normalize_test.go b/internal/resolver/normalize_test.go new file mode 100644 index 0000000..26f0c1d --- /dev/null +++ b/internal/resolver/normalize_test.go @@ -0,0 +1,124 @@ +package resolver + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestNormalizeDomain(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"cyrillic IDN to punycode", "евау.world", "xn--80adi2d.world"}, + {"already punycode is idempotent", "xn--80adi2d.world", "xn--80adi2d.world"}, + {"uppercase ASCII lowercased", "EXAMPLE.COM", "example.com"}, + {"plain ASCII unchanged", "example.com", "example.com"}, + {"trailing dot trimmed", "евау.world.", "xn--80adi2d.world"}, + {"surrounding whitespace trimmed", " example.com ", "example.com"}, + {"IP literal passes through", "8.8.8.8", "8.8.8.8"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeDomain(tt.in) + if err != nil { + t.Fatalf("NormalizeDomain(%q) returned error: %v", tt.in, err) + } + if got != tt.want { + t.Errorf("NormalizeDomain(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNormalizeDomainIDNProducesASCII(t *testing.T) { + for _, in := range []string{"ébay.it", "iphone.セール", "zwick.ελ"} { + got, err := NormalizeDomain(in) + if err != nil { + t.Errorf("NormalizeDomain(%q) returned error: %v", in, err) + continue + } + if utf8.RuneCountInString(got) != len(got) { + t.Errorf("NormalizeDomain(%q) = %q, want all-ASCII", in, got) + } + if !strings.Contains(got, "xn--") { + t.Errorf("NormalizeDomain(%q) = %q, want a punycode (xn--) label", in, got) + } + } +} + +// Malformed IDNs (here a Bidi-rule violation) must be rejected rather than +// passed through as raw Unicode, otherwise a doomed lookup gets dispatched. +func TestNormalizeDomainRejectsInvalidIDN(t *testing.T) { + for _, in := range []string{"صلa1.com", "xn--a.com"} { + if got, err := NormalizeDomain(in); err == nil { + t.Errorf("NormalizeDomain(%q) = %q, want error", in, got) + } + } +} + +func TestRegistrableDomainIDN(t *testing.T) { + // Cases where the exact registrable domain is known. + exact := []struct { + in string + want string + }{ + {"евау.world", "xn--80adi2d.world"}, + {"www.евау.world", "xn--80adi2d.world"}, + } + for _, tt := range exact { + got, err := RegistrableDomain(tt.in) + if err != nil { + t.Errorf("RegistrableDomain(%q) returned error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("RegistrableDomain(%q) = %q, want %q", tt.in, got, tt.want) + } + } + + structural := []string{"ébay.it", "iphone.セール", "zwick.ελ"} + for _, in := range structural { + got, err := RegistrableDomain(in) + if err != nil { + t.Errorf("RegistrableDomain(%q) returned error: %v", in, err) + continue + } + if utf8.RuneCountInString(got) != len(got) { + t.Errorf("RegistrableDomain(%q) = %q, want all-ASCII", in, got) + } + if !strings.Contains(got, "xn--") { + t.Errorf("RegistrableDomain(%q) = %q, want a punycode (xn--) label", in, got) + } + } +} + +func TestRegistrableDomainASCIIRegression(t *testing.T) { + ok := []struct { + in string + want string + }{ + {"mail.google.com", "google.com"}, + {"sub.example.co.jp", "example.co.jp"}, + {"example.com", "example.com"}, + } + for _, tt := range ok { + got, err := RegistrableDomain(tt.in) + if err != nil { + t.Errorf("RegistrableDomain(%q) returned error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("RegistrableDomain(%q) = %q, want %q", tt.in, got, tt.want) + } + } + + bad := []string{"hello", "co.jp", "x"} + for _, in := range bad { + if got, err := RegistrableDomain(in); err == nil { + t.Errorf("RegistrableDomain(%q) = %q, want error", in, got) + } + } +}