domain_sanity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +235 -0
- data/lib/domain_sanity/data.rb +56 -0
- data/lib/domain_sanity/idn.rb +121 -0
- data/lib/domain_sanity/ip.rb +136 -0
- data/lib/domain_sanity/name.rb +274 -0
- data/lib/domain_sanity/policy.rb +114 -0
- data/lib/domain_sanity/reason.rb +20 -0
- data/lib/domain_sanity/subject.rb +229 -0
- data/lib/domain_sanity/version.rb +5 -0
- data/lib/domain_sanity.rb +112 -0
- metadata +92 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "public_suffix"
|
|
5
|
+
require_relative "idn"
|
|
6
|
+
require_relative "ip"
|
|
7
|
+
require_relative "reason"
|
|
8
|
+
require_relative "policy"
|
|
9
|
+
|
|
10
|
+
module DomainSanity
|
|
11
|
+
# Fully-qualified domain name validation.
|
|
12
|
+
#
|
|
13
|
+
# The checks implement widely published standards, not any one vendor's
|
|
14
|
+
# code: RFC 1035 label syntax and length limits, RFC 5890/5891 for IDN,
|
|
15
|
+
# the Public Suffix List for TLD membership, and the CA/Browser Forum
|
|
16
|
+
# Baseline Requirements for wildcard issuance. Structural length checks run
|
|
17
|
+
# on the ASCII (punycode) form, since the DNS limits are octet limits on the
|
|
18
|
+
# wire format; TLD membership runs on the Unicode form, which is what the
|
|
19
|
+
# Public Suffix List expects.
|
|
20
|
+
#
|
|
21
|
+
# Every entry point funnels a subject through {normalize} exactly once, which
|
|
22
|
+
# classifies it (hostname / wildcard / ip / reverse_zone / empty) and does
|
|
23
|
+
# all IDN conversion and Public Suffix parsing up front. Downstream checks
|
|
24
|
+
# read the precomputed form instead of re-converting.
|
|
25
|
+
#
|
|
26
|
+
# What counts as invalid is governed by a {Policy}: underscores, single-label
|
|
27
|
+
# names, trailing dots, private Public Suffix List entries, and script
|
|
28
|
+
# mixing are all policy choices rather than hardcoded rules.
|
|
29
|
+
module Name
|
|
30
|
+
MAX_DOMAIN_LENGTH = 253
|
|
31
|
+
MAX_LABEL_LENGTH = 63
|
|
32
|
+
MAX_LABELS = 127
|
|
33
|
+
|
|
34
|
+
# Hard cap on the raw input, checked before any IDN conversion or Public
|
|
35
|
+
# Suffix parsing so untrusted callers can't force unbounded work with a huge
|
|
36
|
+
# string. No real name comes close: the ASCII form maxes out at 253 octets,
|
|
37
|
+
# and even a pathological all-multibyte IDN stays well under this in its
|
|
38
|
+
# Unicode form. This is a denial-of-service guard, not the DNS length rule
|
|
39
|
+
# (that is MAX_DOMAIN_LENGTH, enforced on the converted ASCII form).
|
|
40
|
+
MAX_INPUT_BYTES = 1024
|
|
41
|
+
|
|
42
|
+
LDH_LABEL = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i
|
|
43
|
+
LDH_UNDERSCORE_LABEL = /\A[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?\z/i
|
|
44
|
+
|
|
45
|
+
NUMERIC = /\A\d+\z/
|
|
46
|
+
|
|
47
|
+
# Special-use TLDs that are not delegated in the Public Suffix List but are
|
|
48
|
+
# legitimate in some contexts. Accepted only when a policy sets
|
|
49
|
+
# allow_reserved_tld (:lenient does). Sources: RFC 6761 (example, invalid,
|
|
50
|
+
# localhost, test), RFC 6762 (local), RFC 7686 (onion), and ICANN's
|
|
51
|
+
# private-use ".internal".
|
|
52
|
+
RESERVED_TLDS = Set[
|
|
53
|
+
"example", "invalid", "localhost", "test", "local", "onion", "internal"
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
# The one-pass normalized view of a subject. `kind` is always set; the
|
|
57
|
+
# remaining fields are populated only for the :hostname kind (the other
|
|
58
|
+
# kinds fail fast with a single reason and need no structural analysis).
|
|
59
|
+
# input - the original string (or nil)
|
|
60
|
+
# name - input with a single trailing "root" dot removed
|
|
61
|
+
# ascii - punycode form of name, or nil if unconvertible
|
|
62
|
+
# unicode - Unicode form of name (falls back to name)
|
|
63
|
+
# labels - ascii split on ".", preserving empty edge labels
|
|
64
|
+
# psl - PublicSuffix::Domain, or nil if not registrable
|
|
65
|
+
Normalized = Struct.new(
|
|
66
|
+
:input, :name, :ascii, :unicode, :labels, :kind, :psl,
|
|
67
|
+
keyword_init: true
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# Classify and pre-parse a subject exactly once. Cheap for the non-hostname
|
|
73
|
+
# kinds, which return early before any IDN/PSL work. `policy` only affects
|
|
74
|
+
# whether private Public Suffix List entries count as suffixes.
|
|
75
|
+
def normalize(subject, policy = Policy.ca_baseline)
|
|
76
|
+
return Normalized.new(input: subject, kind: :nil) if subject.nil?
|
|
77
|
+
return Normalized.new(input: subject, kind: :not_a_string) unless subject.is_a?(String)
|
|
78
|
+
return Normalized.new(input: subject, kind: :oversized) if subject.bytesize > MAX_INPUT_BYTES
|
|
79
|
+
return Normalized.new(input: subject, kind: :empty) if subject.empty?
|
|
80
|
+
return Normalized.new(input: subject, kind: :whitespace) if subject.match?(/\s/)
|
|
81
|
+
|
|
82
|
+
kind = classify(subject)
|
|
83
|
+
return Normalized.new(input: subject, kind: kind) unless kind == :hostname
|
|
84
|
+
|
|
85
|
+
name = subject.chomp(".") # a single trailing dot denotes the same FQDN
|
|
86
|
+
ascii = IDN.to_ascii(name)
|
|
87
|
+
unicode = IDN.to_unicode(name) || name
|
|
88
|
+
Normalized.new(
|
|
89
|
+
input: subject,
|
|
90
|
+
name: name,
|
|
91
|
+
ascii: ascii,
|
|
92
|
+
unicode: unicode,
|
|
93
|
+
labels: ascii ? ascii.split(".", -1) : [],
|
|
94
|
+
kind: :hostname,
|
|
95
|
+
psl: parse_psl(unicode, policy)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when subject is a structurally valid FQDN under the given policy.
|
|
100
|
+
# Wildcards, IP literals, and reverse-zone names are never valid here.
|
|
101
|
+
def valid?(subject, policy: :ca_baseline)
|
|
102
|
+
reasons(subject, policy: policy).empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Structured reasons the subject is not a valid FQDN, as Reason objects.
|
|
106
|
+
# An empty array means valid. This is the single source of truth.
|
|
107
|
+
def reasons(subject, policy: :ca_baseline)
|
|
108
|
+
pol = Policy.coerce(policy)
|
|
109
|
+
reasons_for(normalize(subject, pol), policy: pol)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Reasons for an already-normalized subject, so callers that hold a
|
|
113
|
+
# Normalized (such as a Subject) don't re-parse.
|
|
114
|
+
def reasons_for(norm, policy: :ca_baseline)
|
|
115
|
+
pol = Policy.coerce(policy)
|
|
116
|
+
case norm.kind
|
|
117
|
+
when :nil then [reason(:nil, "is nil")]
|
|
118
|
+
when :not_a_string then [reason(:not_a_string, "is not a string")]
|
|
119
|
+
when :oversized then [reason(:input_too_long, "exceeds the maximum input length of #{MAX_INPUT_BYTES} bytes")]
|
|
120
|
+
when :empty then [reason(:empty, "is empty")]
|
|
121
|
+
when :whitespace then [reason(:whitespace, "contains whitespace")]
|
|
122
|
+
when :wildcard then [reason(:wildcard, "is a wildcard (use valid_wildcard?)")]
|
|
123
|
+
when :ip then [reason(:ip_address, "is an IP address, not a host name")]
|
|
124
|
+
when :reverse_zone then [reason(:reverse_zone, "is a reverse-DNS zone name, not a host name")]
|
|
125
|
+
else hostname_reasons(norm, pol)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# True when subject is itself a recognized public suffix (eTLD), including
|
|
130
|
+
# multi-label suffixes like "co.uk". A full registrable name such as
|
|
131
|
+
# "example.com" is NOT a public suffix and returns false. ICANN suffixes
|
|
132
|
+
# only, unless a policy including private suffixes is passed.
|
|
133
|
+
def valid_tld?(subject, policy: :ca_baseline)
|
|
134
|
+
return false unless subject.is_a?(String)
|
|
135
|
+
return false if subject.empty?
|
|
136
|
+
|
|
137
|
+
pol = Policy.coerce(policy)
|
|
138
|
+
unicode = IDN.to_unicode(subject) || subject
|
|
139
|
+
PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !pol.include_private_suffixes)
|
|
140
|
+
false # parsed as a registrable domain, so it is not a bare suffix
|
|
141
|
+
rescue PublicSuffix::DomainNotAllowed
|
|
142
|
+
true # only a bare public suffix parses to "not allowed"
|
|
143
|
+
rescue PublicSuffix::Error
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# True when subject is a wildcard name of the form "*.something".
|
|
148
|
+
def wildcard?(subject)
|
|
149
|
+
subject.is_a?(String) && subject.start_with?("*.")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# True when subject is a wildcard the Baseline Requirements permit:
|
|
153
|
+
# the "*" is the entire leftmost label, appears exactly once, and the
|
|
154
|
+
# remainder is a valid domain that is NOT itself a bare public suffix.
|
|
155
|
+
def valid_wildcard?(subject, policy: :ca_baseline)
|
|
156
|
+
return false unless wildcard?(subject)
|
|
157
|
+
return false if subject.count("*") != 1
|
|
158
|
+
|
|
159
|
+
pol = Policy.coerce(policy)
|
|
160
|
+
valid_wildcard_remainder?(normalize(subject[2..], pol), policy: pol)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Whether an already-normalized wildcard remainder is eligible to carry a
|
|
164
|
+
# "*." label: it must validate, and it must have something to the left of
|
|
165
|
+
# its public suffix. For PSL names that means an sld (registrable != tld);
|
|
166
|
+
# for policy-permitted names with no PSL entry (a reserved TLD under
|
|
167
|
+
# allow_reserved_tld) it means at least two labels. Shared so Subject and
|
|
168
|
+
# valid_wildcard? normalize the remainder only once.
|
|
169
|
+
def valid_wildcard_remainder?(norm, policy: :ca_baseline)
|
|
170
|
+
pol = Policy.coerce(policy)
|
|
171
|
+
return false unless reasons_for(norm, policy: pol).empty?
|
|
172
|
+
|
|
173
|
+
registrable = norm.psl&.domain
|
|
174
|
+
return registrable != norm.psl.tld unless registrable.nil?
|
|
175
|
+
|
|
176
|
+
norm.labels.size >= 2
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The registrable domain ("example.co.uk" from "www.example.co.uk"), or nil.
|
|
180
|
+
def registrable_domain(subject, policy: :ca_baseline)
|
|
181
|
+
normalize(subject, Policy.coerce(policy)).psl&.domain
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The public suffix ("co.uk" from "www.example.co.uk"), or nil.
|
|
185
|
+
def public_suffix(subject, policy: :ca_baseline)
|
|
186
|
+
normalize(subject, Policy.coerce(policy)).psl&.tld
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# --- internals -------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def classify(subject)
|
|
192
|
+
if subject.start_with?("*.") then :wildcard
|
|
193
|
+
elsif IP.ip?(subject) then :ip
|
|
194
|
+
elsif IP.reverse_zone?(subject) then :reverse_zone
|
|
195
|
+
else :hostname
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_psl(unicode, policy)
|
|
200
|
+
PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !policy.include_private_suffixes)
|
|
201
|
+
rescue PublicSuffix::Error
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def hostname_reasons(norm, policy)
|
|
206
|
+
errors = []
|
|
207
|
+
name = norm.name
|
|
208
|
+
|
|
209
|
+
# Dot-structure checks run on the pre-IDN name, because IDN conversion
|
|
210
|
+
# silently drops empty edge labels and would hide a leading dot.
|
|
211
|
+
errors << reason(:leading_dot, "starts with a dot") if name.start_with?(".")
|
|
212
|
+
errors << reason(:trailing_dot, "ends with a dot") if name.end_with?(".")
|
|
213
|
+
errors << reason(:empty_label, "has an empty label (consecutive or edge dots)") if name.include?("..")
|
|
214
|
+
if !policy.allow_trailing_dot && norm.input.end_with?(".")
|
|
215
|
+
errors << reason(:trailing_dot, "ends with a dot")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
if norm.ascii.nil?
|
|
219
|
+
errors << reason(:not_convertible, "is not convertible to a valid ASCII form")
|
|
220
|
+
return errors.uniq
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
errors << reason(:too_long, "exceeds #{MAX_DOMAIN_LENGTH} characters") if norm.ascii.length > MAX_DOMAIN_LENGTH
|
|
224
|
+
|
|
225
|
+
labels = norm.labels
|
|
226
|
+
single_label = policy.allow_single_label && labels.size == 1
|
|
227
|
+
errors << reason(:too_few_labels, "must have at least two labels") if labels.size < 2 && !single_label
|
|
228
|
+
errors << reason(:too_many_labels, "exceeds #{MAX_LABELS} labels") if labels.size > MAX_LABELS
|
|
229
|
+
|
|
230
|
+
pattern = policy.allow_underscore ? LDH_UNDERSCORE_LABEL : LDH_LABEL
|
|
231
|
+
labels.each { |label| append_label_reason(errors, label, pattern) }
|
|
232
|
+
|
|
233
|
+
append_tld_reasons(errors, norm, single_label, policy)
|
|
234
|
+
|
|
235
|
+
if policy.require_single_script && IDN.mixed_script_unicode?(norm.unicode)
|
|
236
|
+
errors << reason(:mixed_script, "mixes scripts within a label (possible homograph)")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
errors.uniq
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# TLD-level checks only apply when there is a TLD (i.e. more than one label,
|
|
243
|
+
# or single-label names aren't permitted).
|
|
244
|
+
def append_tld_reasons(errors, norm, single_label, policy)
|
|
245
|
+
return if single_label
|
|
246
|
+
|
|
247
|
+
tld = norm.labels.last
|
|
248
|
+
errors << reason(:numeric_tld, "has an all-numeric TLD") if tld&.match?(NUMERIC)
|
|
249
|
+
|
|
250
|
+
return unless norm.psl.nil?
|
|
251
|
+
return if policy.allow_reserved_tld && reserved_tld?(tld)
|
|
252
|
+
|
|
253
|
+
errors << reason(:unknown_tld, "has a TLD that is not in the Public Suffix List")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def reserved_tld?(tld)
|
|
257
|
+
!tld.nil? && RESERVED_TLDS.include?(tld.downcase)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def append_label_reason(errors, label, pattern)
|
|
261
|
+
if label.empty?
|
|
262
|
+
errors << reason(:empty_label, "has an empty label (consecutive or edge dots)")
|
|
263
|
+
elsif label.length > MAX_LABEL_LENGTH
|
|
264
|
+
errors << reason(:label_too_long, "has a label longer than #{MAX_LABEL_LENGTH} characters: #{label.inspect}", label)
|
|
265
|
+
elsif !label.match?(pattern)
|
|
266
|
+
errors << reason(:label_invalid, "has an invalid label: #{label.inspect}", label)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def reason(code, message, label = nil)
|
|
271
|
+
Reason.new(code: code, message: message, label: label)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DomainSanity
|
|
4
|
+
# What counts as "valid" depends on who is asking. A certificate authority,
|
|
5
|
+
# a DNS zone editor, and a lenient form validator disagree about underscores,
|
|
6
|
+
# single-label names, private Public Suffix List entries, and script mixing.
|
|
7
|
+
#
|
|
8
|
+
# Policy captures those choices as data instead of letting them accrete as
|
|
9
|
+
# boolean keyword arguments on every method. Pass a preset symbol, a Hash of
|
|
10
|
+
# overrides, or a Policy instance anywhere a `policy:` argument is accepted;
|
|
11
|
+
# {coerce} turns all three into a Policy.
|
|
12
|
+
#
|
|
13
|
+
# DomainSanity.valid?("_dmarc.example.com", policy: :dns_zone)
|
|
14
|
+
# DomainSanity.valid?("host", policy: { allow_single_label: true })
|
|
15
|
+
#
|
|
16
|
+
# Policy only governs how a *host name* is judged; it never makes valid?
|
|
17
|
+
# accept an IP, wildcard, or reverse-zone subject (those are distinct kinds
|
|
18
|
+
# with their own predicates).
|
|
19
|
+
class Policy
|
|
20
|
+
# Each field defaults to the strict (CA-style) choice: false, except
|
|
21
|
+
# allow_trailing_dot, since a single root dot denotes the same name.
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
allow_underscore: false, # permit "_" in labels (RFC 952/1123 forbid it)
|
|
24
|
+
allow_single_label: false, # permit a bare host with no TLD ("intranet")
|
|
25
|
+
allow_trailing_dot: true, # accept & normalize one trailing root dot
|
|
26
|
+
include_private_suffixes: false, # treat private PSL entries (github.io) as suffixes
|
|
27
|
+
allow_reserved_tld: false, # accept RFC 6761/6762/7686 special-use TLDs (.test, .local, .onion, .internal)
|
|
28
|
+
require_single_script: false # reject confusable mixed-script IDN labels
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
FIELDS = DEFAULTS.keys.freeze
|
|
32
|
+
|
|
33
|
+
# The names of the built-in presets.
|
|
34
|
+
PRESET_NAMES = %i[ca_baseline dns_zone lenient].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader(*FIELDS)
|
|
37
|
+
|
|
38
|
+
def initialize(**opts)
|
|
39
|
+
unknown = opts.keys - FIELDS
|
|
40
|
+
raise ArgumentError, "unknown policy option(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
41
|
+
|
|
42
|
+
DEFAULTS.each { |field, default| instance_variable_set(:"@#{field}", opts.fetch(field, default)) }
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A copy with some fields overridden.
|
|
47
|
+
def with(**overrides)
|
|
48
|
+
self.class.new(**to_h.merge(overrides))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
FIELDS.to_h { |field| [field, public_send(field)] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ==(other)
|
|
56
|
+
other.is_a?(Policy) && other.to_h == to_h
|
|
57
|
+
end
|
|
58
|
+
alias_method :eql?, :==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
to_h.hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Strict, certificate-authority-style defaults.
|
|
66
|
+
def ca_baseline
|
|
67
|
+
new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# DNS zone editing: underscores, single-label hosts, and private suffixes
|
|
71
|
+
# are all fine here. Reserved special-use TLDs are still rejected - a real
|
|
72
|
+
# zone shouldn't contain them.
|
|
73
|
+
def dns_zone
|
|
74
|
+
new(allow_underscore: true, allow_single_label: true, include_private_suffixes: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Everything permissive, including RFC 6761/6762/7686 special-use TLDs
|
|
78
|
+
# (.test, .local, .onion, .internal). Script safety stays off to avoid
|
|
79
|
+
# false positives.
|
|
80
|
+
def lenient
|
|
81
|
+
new(
|
|
82
|
+
allow_underscore: true,
|
|
83
|
+
allow_single_label: true,
|
|
84
|
+
include_private_suffixes: true,
|
|
85
|
+
allow_reserved_tld: true
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def presets
|
|
90
|
+
PRESET_NAMES
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def preset(name)
|
|
94
|
+
case name
|
|
95
|
+
when :ca_baseline then ca_baseline
|
|
96
|
+
when :dns_zone then dns_zone
|
|
97
|
+
when :lenient then lenient
|
|
98
|
+
else raise ArgumentError, "unknown policy preset: #{name.inspect}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Turn a Symbol preset, Hash of overrides, Policy, or nil into a Policy.
|
|
103
|
+
def coerce(arg)
|
|
104
|
+
case arg
|
|
105
|
+
when Policy then arg
|
|
106
|
+
when Symbol then preset(arg)
|
|
107
|
+
when Hash then ca_baseline.with(**arg)
|
|
108
|
+
when nil then ca_baseline
|
|
109
|
+
else raise ArgumentError, "cannot coerce #{arg.inspect} into a Policy"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DomainSanity
|
|
4
|
+
# A single, structured reason a subject failed validation.
|
|
5
|
+
#
|
|
6
|
+
# `code` is a stable Symbol meant for programmatic branching (it does not
|
|
7
|
+
# change when the wording of a message is tweaked). `message` is the
|
|
8
|
+
# human-readable explanation. `label` is the specific offending DNS label
|
|
9
|
+
# when one applies (e.g. a label that is too long or malformed), and nil
|
|
10
|
+
# otherwise.
|
|
11
|
+
#
|
|
12
|
+
# Reason#to_s returns the message, so an array of reasons still interpolates
|
|
13
|
+
# and joins into readable text, while callers that need to react in code can
|
|
14
|
+
# switch on `code`.
|
|
15
|
+
Reason = Struct.new(:code, :message, :label, keyword_init: true) do
|
|
16
|
+
def to_s
|
|
17
|
+
message
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "name"
|
|
4
|
+
require_relative "ip"
|
|
5
|
+
require_relative "idn"
|
|
6
|
+
require_relative "policy"
|
|
7
|
+
|
|
8
|
+
module DomainSanity
|
|
9
|
+
# A classified view of one subject string. {Subject.for} inspects the input
|
|
10
|
+
# once and returns the concrete type that fits it - Hostname, Wildcard,
|
|
11
|
+
# IPSubject, ReverseZone, or MalformedSubject - so a method that only makes
|
|
12
|
+
# sense for one kind (registrable_domain for a host, public? for an IP) lives
|
|
13
|
+
# only on that type instead of returning nil on a god-object.
|
|
14
|
+
#
|
|
15
|
+
# Facts are computed lazily and memoized, so asking one question is cheap and
|
|
16
|
+
# asking many re-parses nothing. `valid?` means the same thing on every
|
|
17
|
+
# subject and matches DomainSanity.valid? exactly: true only for a
|
|
18
|
+
# structurally valid plain host name. Wildcards, IPs, and reverse zones are
|
|
19
|
+
# not "valid" in that sense and expose their own predicates instead.
|
|
20
|
+
class Subject
|
|
21
|
+
attr_reader :input, :policy
|
|
22
|
+
|
|
23
|
+
# Classify `input` under `policy` (a preset symbol, Hash, or Policy) and
|
|
24
|
+
# return the matching Subject subclass.
|
|
25
|
+
def self.for(input, policy: :ca_baseline)
|
|
26
|
+
pol = Policy.coerce(policy)
|
|
27
|
+
norm = Name.normalize(input, pol)
|
|
28
|
+
klass = TYPES.fetch(norm.kind, MalformedSubject)
|
|
29
|
+
klass.new(input, norm, pol)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(input, normalized, policy)
|
|
33
|
+
@input = input
|
|
34
|
+
@norm = normalized
|
|
35
|
+
@policy = policy
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def kind
|
|
39
|
+
@norm.kind
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Only a structurally valid plain host name is "valid"; every other kind
|
|
43
|
+
# overrides nothing and stays false. See Hostname.
|
|
44
|
+
def valid?
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def hostname?
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def wildcard?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_wildcard?
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ip?
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reverse_zone?
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reasons
|
|
69
|
+
@reasons ||= Name.reasons_for(@norm, policy: @policy).freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def punycode?
|
|
73
|
+
return @punycode if defined?(@punycode)
|
|
74
|
+
|
|
75
|
+
@punycode = IDN.punycode?(@input)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ascii
|
|
79
|
+
return @ascii if defined?(@ascii)
|
|
80
|
+
|
|
81
|
+
@ascii = IDN.to_ascii(@input)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def unicode
|
|
85
|
+
return @unicode if defined?(@unicode)
|
|
86
|
+
|
|
87
|
+
@unicode = IDN.to_unicode(@input)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A uniform snapshot: every subject exposes the same keys, with nil where a
|
|
91
|
+
# fact does not apply to this kind. (The typed *methods* stay kind-specific -
|
|
92
|
+
# an IPSubject still has no #registrable_domain - but the serialized shape is
|
|
93
|
+
# stable so downstream `present?`/`dig` checks work the same for any kind.)
|
|
94
|
+
# Subclasses fill the optional slots via {type_facts}.
|
|
95
|
+
def to_h
|
|
96
|
+
{
|
|
97
|
+
input: @input,
|
|
98
|
+
kind: kind,
|
|
99
|
+
valid: valid?,
|
|
100
|
+
wildcard: wildcard?,
|
|
101
|
+
valid_wildcard: valid_wildcard?,
|
|
102
|
+
ip: ip?,
|
|
103
|
+
reserved_ip: nil,
|
|
104
|
+
public_ip: nil,
|
|
105
|
+
reverse_zone: reverse_zone?,
|
|
106
|
+
punycode: punycode?,
|
|
107
|
+
ascii: ascii,
|
|
108
|
+
unicode: unicode,
|
|
109
|
+
registrable_domain: nil,
|
|
110
|
+
public_suffix: nil,
|
|
111
|
+
reasons: reasons.map(&:to_s)
|
|
112
|
+
}.merge(type_facts)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Kind-specific overrides for the optional to_h slots. Base has none.
|
|
116
|
+
def type_facts
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Shared to_h slots for the kinds that carry a registrable domain (Hostname
|
|
122
|
+
# and Wildcard). Each including class supplies its own registrable_domain /
|
|
123
|
+
# public_suffix; this just maps them into the uniform to_h shape.
|
|
124
|
+
module RegistrableTypeFacts
|
|
125
|
+
def type_facts
|
|
126
|
+
{registrable_domain: registrable_domain, public_suffix: public_suffix}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A candidate fully-qualified host name.
|
|
131
|
+
class Hostname < Subject
|
|
132
|
+
include RegistrableTypeFacts
|
|
133
|
+
|
|
134
|
+
def hostname?
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def valid?
|
|
139
|
+
reasons.empty?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def registrable_domain
|
|
143
|
+
@norm.psl&.domain
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def public_suffix
|
|
147
|
+
@norm.psl&.tld
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A "*.something" wildcard name.
|
|
152
|
+
class Wildcard < Subject
|
|
153
|
+
include RegistrableTypeFacts
|
|
154
|
+
|
|
155
|
+
def wildcard?
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Baseline-Requirements-valid wildcard. A wildcard is never a plain
|
|
160
|
+
# host name, so #valid? stays false; this is the predicate to ask. Derived
|
|
161
|
+
# from the single normalized remainder, so nothing re-parses.
|
|
162
|
+
def valid_wildcard?
|
|
163
|
+
return @valid_wildcard if defined?(@valid_wildcard)
|
|
164
|
+
|
|
165
|
+
@valid_wildcard = @input.count("*") == 1 &&
|
|
166
|
+
Name.valid_wildcard_remainder?(remainder, policy: @policy)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The registrable domain / public suffix of the wildcard's base name.
|
|
170
|
+
def registrable_domain
|
|
171
|
+
remainder.psl&.domain
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def public_suffix
|
|
175
|
+
remainder.psl&.tld
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def remainder
|
|
181
|
+
@remainder ||= Name.normalize(@input[2..], @policy)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A single IPv4 or IPv6 host address.
|
|
186
|
+
class IPSubject < Subject
|
|
187
|
+
def ip?
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def reserved?
|
|
192
|
+
return @reserved if defined?(@reserved)
|
|
193
|
+
|
|
194
|
+
@reserved = IP.reserved?(@input)
|
|
195
|
+
end
|
|
196
|
+
alias_method :reserved_ip?, :reserved?
|
|
197
|
+
|
|
198
|
+
def public?
|
|
199
|
+
return @public if defined?(@public)
|
|
200
|
+
|
|
201
|
+
@public = IP.public?(@input)
|
|
202
|
+
end
|
|
203
|
+
alias_method :public_ip?, :public?
|
|
204
|
+
|
|
205
|
+
def type_facts
|
|
206
|
+
{reserved_ip: reserved?, public_ip: public?}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# An in-addr.arpa / ip6.arpa reverse-DNS zone name.
|
|
211
|
+
class ReverseZone < Subject
|
|
212
|
+
def reverse_zone?
|
|
213
|
+
true
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# nil, empty, or whitespace-bearing input - no meaningful structure.
|
|
218
|
+
class MalformedSubject < Subject
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
class Subject
|
|
222
|
+
TYPES = {
|
|
223
|
+
hostname: Hostname,
|
|
224
|
+
wildcard: Wildcard,
|
|
225
|
+
ip: IPSubject,
|
|
226
|
+
reverse_zone: ReverseZone
|
|
227
|
+
}.freeze
|
|
228
|
+
end
|
|
229
|
+
end
|