DomainSanity
Strict, standards-based domain name validation and inspection for Ruby, written the way a certificate authority has to think about names.
Most "is this a valid domain" helpers stop at a regex. A CA cannot. It has to
know that a label may not exceed 63 octets, that the whole name may not exceed
253, that example.123 has an all-numeric TLD, that *.co.uk is a forbidden
wildcard while *.example.com is fine, that münchen.de and its punycode
form xn--mnchen-3ya.de are the same name, and that 10.0.0.1 and
1.2.0.192.in-addr.arpa are not issuable host names at all.
DomainSanity packages that judgment behind a small, fast API with two lean, pure-Ruby dependencies.
Why it exists
- Standards, not guesswork. RFC 1035 label and length rules, RFC 5890/5891 for internationalized names, the ICANN Public Suffix List for TLD membership, and CA/Browser Forum Baseline Requirements for wildcards.
- Small and fast. Two runtime dependencies (
public_suffix,simpleidn), both pure Ruby.IPAddrcomes from the standard library. - Honest answers.
reasonstells you why a name failed, so you can show a useful error instead of a shrug. - Policy, not hardcoded rules. "Valid" means different things to a CA, a
DNS zone editor, and a lenient form validator. Choose with a
policy:.
Scope
DomainSanity is offline, structural validation only. It does not resolve
DNS, check CAA records, confirm a name exists, enforce certificate field
lengths (e.g. CN <= 64), or perform full UTS-46 / IDNA2008 processing - IDN
conversion is IDNA2003-style punycode via SimpleIDN (see IDN and homographs).
Those remain the caller's responsibility. What it does do it does strictly
and fast, with structured, machine-readable answers.
Input longer than 1024 bytes is rejected up front (before any IDN or Public Suffix work) as a denial-of-service guard - no real domain name approaches that.
Using this for SSRF defense? Non-canonical IP encodings (010.0.0.1,
0x7f.0.0.1, 2130706433) are not recognized as IPs - they're treated as
(invalid) host names, so reserved_ip? / public_ip? return false for them.
That means they can't bypass the reserved-range check here, but a downstream
HTTP client or resolver might still interpret them as addresses. Canonicalize
addresses yourself before trusting any allow/deny decision.
Installation
gem "domain_sanity"
Then bundle install, or gem install domain_sanity.
Usage
require "domain_sanity"
DomainSanity.valid?("example.com") # => true
DomainSanity.valid?("www.example.co.uk") # => true
DomainSanity.valid?("münchen.de") # => true (IDN)
DomainSanity.valid?("example.123") # => false (all-numeric TLD)
DomainSanity.valid?("example.nope") # => false (unknown TLD)
DomainSanity.valid?("-bad.example.com") # => false (leading hyphen)
# Tell the user what went wrong. `reasons` returns structured Reason objects
# (code, message, offending label); each renders to its message as a string.
DomainSanity.reasons("-bad.example.123").map(&:message)
# => ["has an invalid label: \"-bad\"", "has an all-numeric TLD",
# "has a TLD that is not in the Public Suffix List"]
DomainSanity.reasons("-bad.example.123").map(&:code)
# => [:label_invalid, :numeric_tld, :unknown_tld]
# IP literals and reverse zones are not host names, so `valid?` rejects them.
DomainSanity.valid?("192.0.2.10") # => false (IP, not a name)
DomainSanity.valid?("1.2.0.192.in-addr.arpa") # => false (reverse zone)
# `valid_tld?` asks whether the argument is itself a public suffix (eTLD).
DomainSanity.valid_tld?("com") # => true
DomainSanity.valid_tld?("co.uk") # => true
DomainSanity.valid_tld?("example.com") # => false (a name, not a suffix)
# Wildcards, per the Baseline Requirements
DomainSanity.valid_wildcard?("*.example.com") # => true
DomainSanity.valid_wildcard?("*.co.uk") # => false (bare public suffix)
DomainSanity.valid_wildcard?("ba*.example.com") # => false (embedded)
# IP addresses and reverse zones
DomainSanity.ip?("192.0.2.10") # => true
DomainSanity.reserved_ip?("10.0.0.1") # => true (RFC 1918)
DomainSanity.public_ip?("1.1.1.1") # => true
DomainSanity.reverse_zone?("1.2.0.192.in-addr.arpa") # => true
# Registrable domain and public suffix
DomainSanity.registrable_domain("a.b.example.co.uk") # => "example.co.uk"
DomainSanity.public_suffix("a.b.example.co.uk") # => "co.uk"
# IDN round-tripping
DomainSanity.to_ascii("münchen.de") # => "xn--mnchen-3ya.de"
DomainSanity.to_unicode("xn--mnchen-3ya.de") # => "münchen.de"
Analyze once, ask many times
When you have several questions about one name, analyze classifies it once and
returns a typed subject - a Hostname, Wildcard, IPSubject,
ReverseZone, or MalformedSubject. Facts are computed lazily and memoized, so
one question is cheap and many re-parse nothing:
r = DomainSanity.analyze("www.example.co.uk") # => DomainSanity::Hostname
r.valid? # => true
r.kind # => :hostname
r.registrable_domain # => "example.co.uk"
r.public_suffix # => "co.uk"
r.to_h # => { input:, kind:, valid:, registrable_domain:, ... }
ip = DomainSanity.analyze("10.0.0.1") # => DomainSanity::IPSubject
ip.reserved? # => true
ip.public? # => false
ip.registrable_domain # => NoMethodError - an IP has no registrable domain
Methods that only make sense for one kind live only on that type, so you can't
ask an IP for its registrable domain. The entry point is analyze (not
inspect, which would shadow Object#inspect). Subject#valid? means exactly
what DomainSanity.valid? means: a structurally valid plain host name.
Wildcards, IPs, and reverse zones are not "valid" in that sense and expose their
own predicates (valid_wildcard?, public?).
While the methods are kind-specific, to_h is uniform: every subject
serializes the same key set, with nil where a fact does not apply to that kind,
so downstream dig/present? checks work the same for any input.
Policies
What counts as valid is a Policy. Pass policy: a preset symbol, a Hash of
overrides, or a Policy instance; the default is :ca_baseline.
DomainSanity.valid?("_dmarc.example.com") # => false
DomainSanity.valid?("_dmarc.example.com", policy: :dns_zone) # => true
DomainSanity.valid?("_dmarc.example.com", policy: { allow_underscore: true })
DomainSanity.valid?("intranet-host", policy: :dns_zone) # => true (single label)
DomainSanity.public_suffix("foo.github.io", policy: :dns_zone) # => "github.io"
| Field | :ca_baseline |
Meaning |
|---|---|---|
allow_underscore |
false |
permit _ in labels (RFC 952/1123 forbid it) |
allow_single_label |
false |
permit a bare host with no TLD (intranet) |
allow_trailing_dot |
true |
accept & normalize one trailing root dot |
include_private_suffixes |
false |
treat private PSL entries (github.io) as suffixes |
allow_reserved_tld |
false |
accept special-use TLDs (.test, .local, .onion, .internal) |
require_single_script |
false |
reject confusable mixed-script IDN labels |
Presets: :ca_baseline (strict, the default), :dns_zone (underscores,
single-label hosts, and private suffixes allowed), :lenient (all of that plus
RFC 6761/6762/7686 special-use TLDs).
allow_reserved_tld only affects validity - a reserved TLD is not in the Public
Suffix List, so registrable_domain / public_suffix stay nil for names like
foo.test.
IDN and homographs
IDN conversion (to_ascii / to_unicode) uses SimpleIDN, which is IDNA2003
punycode - not a full UTS-46 / IDNA2008 processor. Mapping of a few code
points (final sigma, ß, ZWJ/ZWNJ) differs from the current standard; normalize
upstream with a UTS-46 library if you need that exactness.
mixed_script? is a lightweight homograph guard that flags labels combining
scripts a Unicode registry would not allow (Latin with Cyrillic, etc.), while
permitting legitimate combinations like Japanese (例え.jp):
DomainSanity.mixed_script?("аpple.com") # => true (Cyrillic "а" + Latin)
DomainSanity.mixed_script?("münchen.de") # => false
DomainSanity.valid?("аpple.com", policy: { require_single_script: true }) # => false
Notes
- A single trailing dot is accepted by default (
allow_trailing_dot).example.com.denotes the same name asexample.com; a second trailing dot (example.com..) is an empty label and is rejected. valid?is consistent everywhere.DomainSanity.valid?(x)andanalyze(x).valid?always agree: both mean "a structurally valid plain host name," so wildcards, IPs, and reverse zones returnfalse.- Data provenance.
DomainSanity.data_versionsreports the reserved-IP snapshot date and the PSL / IDNA gem versions;rake data:checkfails when the vendored IP snapshot goes stale.
What it checks
| Rule | Source |
|---|---|
| Label 1-63 octets, name <= 253 octets, <= 127 labels | RFC 1035 |
| Letter-digit-hyphen labels, no leading/trailing hyphen | RFC 952 / 1123 |
| TLD not all-numeric | RFC 3696 |
| TLD present in the ICANN Public Suffix List | Public Suffix List |
| IDN normalized before suffix checks | RFC 5890 / 5891 |
| Wildcard only as the whole leftmost label, never a bare suffix | CA/B Forum BR 3.2.2.6 |
| Private / reserved IP detection | RFC 1918 / 4193 / 6890 |
| Reverse-zone detection | RFC 1035 / 3596 |
Development
bundle install
bundle exec rake spec # run the test suite
bundle exec standardrb # lint (Standard Ruby)
bundle exec rake data:check # verify the vendored reserved-IP snapshot is current
The reserved-IP ranges are vendored by hand; rake data:sync prints the refresh
steps, and rake data:check (also run in CI) fails when the snapshot goes stale.
Contributing
- Fork it
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes
- Push to the branch
- Create a Pull Request
Please make sure bundle exec rake spec and bundle exec standardrb both pass.
License
Available as open source under the MIT License.