Module: DomainSanity::Name
- Defined in:
- lib/domain_sanity/name.rb
Overview
Fully-qualified domain name validation.
The checks implement widely published standards, not any one vendor's code: RFC 1035 label syntax and length limits, RFC 5890/5891 for IDN, the Public Suffix List for TLD membership, and the CA/Browser Forum Baseline Requirements for wildcard issuance. Structural length checks run on the ASCII (punycode) form, since the DNS limits are octet limits on the wire format; TLD membership runs on the Unicode form, which is what the Public Suffix List expects.
Every entry point funnels a subject through Name.normalize exactly once, which classifies it (hostname / wildcard / ip / reverse_zone / empty) and does all IDN conversion and Public Suffix parsing up front. Downstream checks read the precomputed form instead of re-converting.
What counts as invalid is governed by a Policy: underscores, single-label names, trailing dots, private Public Suffix List entries, and script mixing are all policy choices rather than hardcoded rules.
Defined Under Namespace
Classes: Normalized
Constant Summary collapse
- MAX_DOMAIN_LENGTH =
253- MAX_LABEL_LENGTH =
63- MAX_LABELS =
127- MAX_INPUT_BYTES =
Hard cap on the raw input, checked before any IDN conversion or Public Suffix parsing so untrusted callers can't force unbounded work with a huge string. No real name comes close: the ASCII form maxes out at 253 octets, and even a pathological all-multibyte IDN stays well under this in its Unicode form. This is a denial-of-service guard, not the DNS length rule (that is MAX_DOMAIN_LENGTH, enforced on the converted ASCII form).
1024- LDH_LABEL =
/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i- LDH_UNDERSCORE_LABEL =
/\A[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?\z/i- NUMERIC =
/\A\d+\z/- RESERVED_TLDS =
Special-use TLDs that are not delegated in the Public Suffix List but are legitimate in some contexts. Accepted only when a policy sets allow_reserved_tld (:lenient does). Sources: RFC 6761 (example, invalid, localhost, test), RFC 6762 (local), RFC 7686 (onion), and ICANN's private-use ".internal".
Set[ "example", "invalid", "localhost", "test", "local", "onion", "internal" ].freeze
Class Method Summary collapse
- .append_label_reason(errors, label, pattern) ⇒ Object
-
.append_tld_reasons(errors, norm, single_label, policy) ⇒ Object
TLD-level checks only apply when there is a TLD (i.e. more than one label, or single-label names aren't permitted).
-
.classify(subject) ⇒ Object
--- internals -------------------------------------------------------.
- .hostname_reasons(norm, policy) ⇒ Object
-
.normalize(subject, policy = Policy.ca_baseline) ⇒ Object
Classify and pre-parse a subject exactly once.
- .parse_psl(unicode, policy) ⇒ Object
-
.public_suffix(subject, policy: :ca_baseline) ⇒ Object
The public suffix ("co.uk" from "www.example.co.uk"), or nil.
- .reason(code, message, label = nil) ⇒ Object
-
.reasons(subject, policy: :ca_baseline) ⇒ Object
Structured reasons the subject is not a valid FQDN, as Reason objects.
-
.reasons_for(norm, policy: :ca_baseline) ⇒ Object
Reasons for an already-normalized subject, so callers that hold a Normalized (such as a Subject) don't re-parse.
-
.registrable_domain(subject, policy: :ca_baseline) ⇒ Object
The registrable domain ("example.co.uk" from "www.example.co.uk"), or nil.
- .reserved_tld?(tld) ⇒ Boolean
-
.valid?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is a structurally valid FQDN under the given policy.
-
.valid_tld?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is itself a recognized public suffix (eTLD), including multi-label suffixes like "co.uk".
-
.valid_wildcard?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is a wildcard the Baseline Requirements permit: the "*" is the entire leftmost label, appears exactly once, and the remainder is a valid domain that is NOT itself a bare public suffix.
-
.valid_wildcard_remainder?(norm, policy: :ca_baseline) ⇒ Boolean
Whether an already-normalized wildcard remainder is eligible to carry a "*." label: it must validate, and it must have something to the left of its public suffix.
-
.wildcard?(subject) ⇒ Boolean
True when subject is a wildcard name of the form "*.something".
Class Method Details
.append_label_reason(errors, label, pattern) ⇒ Object
260 261 262 263 264 265 266 267 268 |
# File 'lib/domain_sanity/name.rb', line 260 def append_label_reason(errors, label, pattern) if label.empty? errors << reason(:empty_label, "has an empty label (consecutive or edge dots)") elsif label.length > MAX_LABEL_LENGTH errors << reason(:label_too_long, "has a label longer than #{MAX_LABEL_LENGTH} characters: #{label.inspect}", label) elsif !label.match?(pattern) errors << reason(:label_invalid, "has an invalid label: #{label.inspect}", label) end end |
.append_tld_reasons(errors, norm, single_label, policy) ⇒ Object
TLD-level checks only apply when there is a TLD (i.e. more than one label, or single-label names aren't permitted).
244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/domain_sanity/name.rb', line 244 def append_tld_reasons(errors, norm, single_label, policy) return if single_label tld = norm.labels.last errors << reason(:numeric_tld, "has an all-numeric TLD") if tld&.match?(NUMERIC) return unless norm.psl.nil? return if policy.allow_reserved_tld && reserved_tld?(tld) errors << reason(:unknown_tld, "has a TLD that is not in the Public Suffix List") end |
.classify(subject) ⇒ Object
--- internals -------------------------------------------------------
191 192 193 194 195 196 197 |
# File 'lib/domain_sanity/name.rb', line 191 def classify(subject) if subject.start_with?("*.") then :wildcard elsif IP.ip?(subject) then :ip elsif IP.reverse_zone?(subject) then :reverse_zone else :hostname end end |
.hostname_reasons(norm, policy) ⇒ Object
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/domain_sanity/name.rb', line 205 def hostname_reasons(norm, policy) errors = [] name = norm.name # Dot-structure checks run on the pre-IDN name, because IDN conversion # silently drops empty edge labels and would hide a leading dot. errors << reason(:leading_dot, "starts with a dot") if name.start_with?(".") errors << reason(:trailing_dot, "ends with a dot") if name.end_with?(".") errors << reason(:empty_label, "has an empty label (consecutive or edge dots)") if name.include?("..") if !policy.allow_trailing_dot && norm.input.end_with?(".") errors << reason(:trailing_dot, "ends with a dot") end if norm.ascii.nil? errors << reason(:not_convertible, "is not convertible to a valid ASCII form") return errors.uniq end errors << reason(:too_long, "exceeds #{MAX_DOMAIN_LENGTH} characters") if norm.ascii.length > MAX_DOMAIN_LENGTH labels = norm.labels single_label = policy.allow_single_label && labels.size == 1 errors << reason(:too_few_labels, "must have at least two labels") if labels.size < 2 && !single_label errors << reason(:too_many_labels, "exceeds #{MAX_LABELS} labels") if labels.size > MAX_LABELS pattern = policy.allow_underscore ? LDH_UNDERSCORE_LABEL : LDH_LABEL labels.each { |label| append_label_reason(errors, label, pattern) } append_tld_reasons(errors, norm, single_label, policy) if policy.require_single_script && IDN.mixed_script_unicode?(norm.unicode) errors << reason(:mixed_script, "mixes scripts within a label (possible homograph)") end errors.uniq end |
.normalize(subject, policy = Policy.ca_baseline) ⇒ Object
Classify and pre-parse a subject exactly once. Cheap for the non-hostname
kinds, which return early before any IDN/PSL work. policy only affects
whether private Public Suffix List entries count as suffixes.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/domain_sanity/name.rb', line 75 def normalize(subject, policy = Policy.ca_baseline) return Normalized.new(input: subject, kind: :nil) if subject.nil? return Normalized.new(input: subject, kind: :not_a_string) unless subject.is_a?(String) return Normalized.new(input: subject, kind: :oversized) if subject.bytesize > MAX_INPUT_BYTES return Normalized.new(input: subject, kind: :empty) if subject.empty? return Normalized.new(input: subject, kind: :whitespace) if subject.match?(/\s/) kind = classify(subject) return Normalized.new(input: subject, kind: kind) unless kind == :hostname name = subject.chomp(".") # a single trailing dot denotes the same FQDN ascii = IDN.to_ascii(name) unicode = IDN.to_unicode(name) || name Normalized.new( input: subject, name: name, ascii: ascii, unicode: unicode, labels: ascii ? ascii.split(".", -1) : [], kind: :hostname, psl: parse_psl(unicode, policy) ) end |
.parse_psl(unicode, policy) ⇒ Object
199 200 201 202 203 |
# File 'lib/domain_sanity/name.rb', line 199 def parse_psl(unicode, policy) PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !policy.include_private_suffixes) rescue PublicSuffix::Error nil end |
.public_suffix(subject, policy: :ca_baseline) ⇒ Object
The public suffix ("co.uk" from "www.example.co.uk"), or nil.
185 186 187 |
# File 'lib/domain_sanity/name.rb', line 185 def public_suffix(subject, policy: :ca_baseline) normalize(subject, Policy.coerce(policy)).psl&.tld end |
.reason(code, message, label = nil) ⇒ Object
270 271 272 |
# File 'lib/domain_sanity/name.rb', line 270 def reason(code, , label = nil) Reason.new(code: code, message: , label: label) end |
.reasons(subject, policy: :ca_baseline) ⇒ Object
Structured reasons the subject is not a valid FQDN, as Reason objects. An empty array means valid. This is the single source of truth.
107 108 109 110 |
# File 'lib/domain_sanity/name.rb', line 107 def reasons(subject, policy: :ca_baseline) pol = Policy.coerce(policy) reasons_for(normalize(subject, pol), policy: pol) end |
.reasons_for(norm, policy: :ca_baseline) ⇒ Object
Reasons for an already-normalized subject, so callers that hold a Normalized (such as a Subject) don't re-parse.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/domain_sanity/name.rb', line 114 def reasons_for(norm, policy: :ca_baseline) pol = Policy.coerce(policy) case norm.kind when :nil then [reason(:nil, "is nil")] when :not_a_string then [reason(:not_a_string, "is not a string")] when :oversized then [reason(:input_too_long, "exceeds the maximum input length of #{MAX_INPUT_BYTES} bytes")] when :empty then [reason(:empty, "is empty")] when :whitespace then [reason(:whitespace, "contains whitespace")] when :wildcard then [reason(:wildcard, "is a wildcard (use valid_wildcard?)")] when :ip then [reason(:ip_address, "is an IP address, not a host name")] when :reverse_zone then [reason(:reverse_zone, "is a reverse-DNS zone name, not a host name")] else hostname_reasons(norm, pol) end end |
.registrable_domain(subject, policy: :ca_baseline) ⇒ Object
The registrable domain ("example.co.uk" from "www.example.co.uk"), or nil.
180 181 182 |
# File 'lib/domain_sanity/name.rb', line 180 def registrable_domain(subject, policy: :ca_baseline) normalize(subject, Policy.coerce(policy)).psl&.domain end |
.reserved_tld?(tld) ⇒ Boolean
256 257 258 |
# File 'lib/domain_sanity/name.rb', line 256 def reserved_tld?(tld) !tld.nil? && RESERVED_TLDS.include?(tld.downcase) end |
.valid?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is a structurally valid FQDN under the given policy. Wildcards, IP literals, and reverse-zone names are never valid here.
101 102 103 |
# File 'lib/domain_sanity/name.rb', line 101 def valid?(subject, policy: :ca_baseline) reasons(subject, policy: policy).empty? end |
.valid_tld?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is itself a recognized public suffix (eTLD), including multi-label suffixes like "co.uk". A full registrable name such as "example.com" is NOT a public suffix and returns false. ICANN suffixes only, unless a policy including private suffixes is passed.
133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/domain_sanity/name.rb', line 133 def valid_tld?(subject, policy: :ca_baseline) return false unless subject.is_a?(String) return false if subject.empty? pol = Policy.coerce(policy) unicode = IDN.to_unicode(subject) || subject PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !pol.include_private_suffixes) false # parsed as a registrable domain, so it is not a bare suffix rescue PublicSuffix::DomainNotAllowed true # only a bare public suffix parses to "not allowed" rescue PublicSuffix::Error false end |
.valid_wildcard?(subject, policy: :ca_baseline) ⇒ Boolean
True when subject is a wildcard the Baseline Requirements permit: the "*" is the entire leftmost label, appears exactly once, and the remainder is a valid domain that is NOT itself a bare public suffix.
155 156 157 158 159 160 161 |
# File 'lib/domain_sanity/name.rb', line 155 def valid_wildcard?(subject, policy: :ca_baseline) return false unless wildcard?(subject) return false if subject.count("*") != 1 pol = Policy.coerce(policy) valid_wildcard_remainder?(normalize(subject[2..], pol), policy: pol) end |
.valid_wildcard_remainder?(norm, policy: :ca_baseline) ⇒ Boolean
Whether an already-normalized wildcard remainder is eligible to carry a "*." label: it must validate, and it must have something to the left of its public suffix. For PSL names that means an sld (registrable != tld); for policy-permitted names with no PSL entry (a reserved TLD under allow_reserved_tld) it means at least two labels. Shared so Subject and valid_wildcard? normalize the remainder only once.
169 170 171 172 173 174 175 176 177 |
# File 'lib/domain_sanity/name.rb', line 169 def valid_wildcard_remainder?(norm, policy: :ca_baseline) pol = Policy.coerce(policy) return false unless reasons_for(norm, policy: pol).empty? registrable = norm.psl&.domain return registrable != norm.psl.tld unless registrable.nil? norm.labels.size >= 2 end |
.wildcard?(subject) ⇒ Boolean
True when subject is a wildcard name of the form "*.something".
148 149 150 |
# File 'lib/domain_sanity/name.rb', line 148 def wildcard?(subject) subject.is_a?(String) && subject.start_with?("*.") end |