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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 489db30b1ec1921a7fb3b1a44a99b893109ec8eed3aea29713204d21600a6678
4
+ data.tar.gz: 6a5d8a6e1fa962ea435d2c495290558c172071c9b94b6b00ff228bb576d3e0a7
5
+ SHA512:
6
+ metadata.gz: 8cb2cd8da5067e0596f8b7447a45d6941fa60ada75188a046894e826038d9a9e850d5c4724a7ec3a6625cbe4ba4e82280b5ef31637ca6db5ff5a00d009029e6b
7
+ data.tar.gz: c7c9bb1b1b7b3a0b933e85e1697d7420862e5bf2488fe347c1598dc8bea46f0f4bba5e3b60d85b3e5c2b75bee34541db86ebccac370274ed8e6a8d7075b0cc06
data/CHANGELOG.md ADDED
@@ -0,0 +1,95 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-07-13
8
+
9
+ ### Added
10
+
11
+ - Initial release.
12
+ - FQDN validation per RFC 1035 label and length rules.
13
+ - IDN / punycode round-tripping via SimpleIDN.
14
+ - TLD membership checks against the ICANN Public Suffix List.
15
+ - CA/Browser Forum aware wildcard validation.
16
+ - IP address, private/reserved range, and reverse-zone detection.
17
+ - `reasons` returns structured `Reason` objects (stable `code`, human-readable
18
+ `message`, offending `label`) that still render to their message as strings.
19
+ - A single trailing "root" dot (`example.com.`) is accepted and normalized.
20
+ - `IP.public?` / `DomainSanity.public_ip?` parse the address only once.
21
+ - **`Policy`** makes validation choices data instead of boolean flags: presets
22
+ `:ca_baseline` (default), `:dns_zone`, `:lenient`, plus per-field overrides
23
+ (`allow_underscore`, `allow_single_label`, `allow_trailing_dot`,
24
+ `include_private_suffixes`, `allow_reserved_tld`, `require_single_script`).
25
+ Every entry point takes a `policy:` argument (a Symbol, Hash, or `Policy`).
26
+ - `allow_reserved_tld` accepts RFC 6761/6762/7686 special-use TLDs (`.test`,
27
+ `.local`, `.onion`, `.internal`, …) that are not in the Public Suffix List;
28
+ `:lenient` enables it, which is what distinguishes it from `:dns_zone`.
29
+ - **Typed subjects.** `analyze` classifies the input once and returns a
30
+ `Hostname`, `Wildcard`, `IPSubject`, `ReverseZone`, or `MalformedSubject`.
31
+ Kind-specific methods live only on the type they apply to (an `IPSubject` has
32
+ no `registrable_domain`). Facts are computed lazily and memoized.
33
+ - **Homograph guard.** `IDN.mixed_script?` / `DomainSanity.mixed_script?` flag
34
+ labels that combine scripts a Unicode registry would disallow (Latin with
35
+ Cyrillic, etc.), while permitting legitimate combinations (Japanese, Chinese,
36
+ Korean). Enforced during validation via `require_single_script`.
37
+ - **Data provenance.** `DomainSanity.data_versions` reports the reserved-IP
38
+ snapshot date and PSL / IDNA gem versions; `rake data:check` fails when the
39
+ vendored IP snapshot is stale; `rake data:sync` documents the refresh steps.
40
+
41
+ ### Design decisions
42
+
43
+ - `valid?` rejects IP literals and reverse-DNS zone names (`in-addr.arpa` /
44
+ `ip6.arpa`) as well as wildcards; `DomainSanity.valid?` and
45
+ `Subject#valid?` are guaranteed to agree for every input.
46
+ - `valid_tld?` answers "is this argument itself a public suffix" (`com`,
47
+ `co.uk` → true; `example.com` → false), rather than validating a whole name.
48
+ - Public-suffix treatment is policy-driven: ICANN-only by default, with private
49
+ entries (`github.io`) enabled via `include_private_suffixes`. Whichever is in
50
+ effect is applied consistently to validity, `registrable_domain`,
51
+ `public_suffix`, and wildcard checks.
52
+ - The inspection entry point is `DomainSanity.analyze` (was `.inspect`, which
53
+ shadowed `Object#inspect`); it returns a `DomainSanity::Subject` subclass
54
+ (the earlier single `Analysis` / `Result` object is gone).
55
+ - `ip?` accepts only single host addresses; prefix / CIDR notation such as
56
+ `10.0.0.0/8` returns false.
57
+ - Reserved-range lists extended to match the current IANA special-purpose
58
+ registries (e.g. `192.31.196.0/24`, `3fff::/20`, `5f00::/16`).
59
+
60
+ ### Security
61
+
62
+ - Input longer than `MAX_INPUT_BYTES` (1024) is rejected up front with an
63
+ `:input_too_long` reason, before any IDN conversion or Public Suffix parsing,
64
+ so an untrusted caller can't force unbounded CPU/memory with a huge string.
65
+ - Documented that non-canonical IP encodings (`010.0.0.1`, `0x7f.0.0.1`,
66
+ `2130706433`) are treated as host names, not IPs; callers using
67
+ `reserved_ip?` for SSRF defense should canonicalize first.
68
+
69
+ ### Fixed
70
+
71
+ - Non-String input (Integer, Symbol, Array, …) is now classified as
72
+ `:not_a_string` and reported invalid, instead of raising `NoMethodError` from
73
+ the validation path.
74
+ - `valid_wildcard?` honors `allow_reserved_tld`: under `:lenient`, a wildcard on
75
+ a reserved-TLD name (`*.foo.test`) is permitted, matching `valid?("foo.test")`.
76
+ A bare reserved TLD (`*.test`) is still rejected.
77
+ - `IP.parse` skips `IPAddr.new` for strings that can't be an address, avoiding a
78
+ raised-and-rescued exception on the hot path for every non-IP host name.
79
+ - `Subject#to_h` exposes a uniform key set across all kinds (nil where a fact
80
+ does not apply), so serialized output has a stable shape; the typed methods
81
+ remain kind-specific.
82
+
83
+ ### Changed
84
+
85
+ - `Policy.preset` dispatches via a case instead of rebuilding a hash of bound
86
+ methods on every call; `Policy.presets` now returns the preset names.
87
+ - The wildcard remainder is normalized once (shared
88
+ `Name.valid_wildcard_remainder?`), and the mixed-script check reuses the
89
+ already-decoded Unicode form.
90
+
91
+ ### Scope
92
+
93
+ - Offline, structural validation only: no DNS resolution, CAA, existence
94
+ checks, or certificate field-length enforcement. IDN conversion is IDNA2003
95
+ punycode (SimpleIDN), not full UTS-46 / IDNA2008.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleyman Musayev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # DomainSanity
2
+
3
+ [![CI](https://github.com/msuliq/domain_sanity/actions/workflows/pull_request.yml/badge.svg)](https://github.com/msuliq/domain_sanity/actions/workflows/pull_request.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/domain_sanity.svg)](https://rubygems.org/gems/domain_sanity)
5
+
6
+ Strict, standards-based domain name validation and inspection for Ruby,
7
+ written the way a certificate authority has to think about names.
8
+
9
+ Most "is this a valid domain" helpers stop at a regex. A CA cannot. It has to
10
+ know that a label may not exceed 63 octets, that the whole name may not exceed
11
+ 253, that `example.123` has an all-numeric TLD, that `*.co.uk` is a forbidden
12
+ wildcard while `*.example.com` is fine, that `münchen.de` and its punycode
13
+ form `xn--mnchen-3ya.de` are the same name, and that `10.0.0.1` and
14
+ `1.2.0.192.in-addr.arpa` are not issuable host names at all.
15
+
16
+ DomainSanity packages that judgment behind a small, fast API with two lean,
17
+ pure-Ruby dependencies.
18
+
19
+ ## Why it exists
20
+
21
+ - **Standards, not guesswork.** RFC 1035 label and length rules, RFC
22
+ 5890/5891 for internationalized names, the ICANN Public Suffix List for TLD
23
+ membership, and CA/Browser Forum Baseline Requirements for wildcards.
24
+ - **Small and fast.** Two runtime dependencies (`public_suffix`, `simpleidn`),
25
+ both pure Ruby. `IPAddr` comes from the standard library.
26
+ - **Honest answers.** `reasons` tells you *why* a name failed, so you can show
27
+ a useful error instead of a shrug.
28
+ - **Policy, not hardcoded rules.** "Valid" means different things to a CA, a
29
+ DNS zone editor, and a lenient form validator. Choose with a `policy:`.
30
+
31
+ ## Scope
32
+
33
+ DomainSanity is **offline, structural validation only**. It does not resolve
34
+ DNS, check CAA records, confirm a name exists, enforce certificate field
35
+ lengths (e.g. `CN <= 64`), or perform full UTS-46 / IDNA2008 processing - IDN
36
+ conversion is IDNA2003-style punycode via SimpleIDN (see [IDN and homographs](#idn-and-homographs)).
37
+ Those remain the caller's responsibility. What it *does* do it does strictly
38
+ and fast, with structured, machine-readable answers.
39
+
40
+ Input longer than 1024 bytes is rejected up front (before any IDN or Public
41
+ Suffix work) as a denial-of-service guard - no real domain name approaches that.
42
+
43
+ **Using this for SSRF defense?** Non-canonical IP encodings (`010.0.0.1`,
44
+ `0x7f.0.0.1`, `2130706433`) are *not* recognized as IPs - they're treated as
45
+ (invalid) host names, so `reserved_ip?` / `public_ip?` return `false` for them.
46
+ That means they can't bypass the reserved-range check *here*, but a downstream
47
+ HTTP client or resolver might still interpret them as addresses. Canonicalize
48
+ addresses yourself before trusting any allow/deny decision.
49
+
50
+ ## Installation
51
+
52
+ ```ruby
53
+ gem "domain_sanity"
54
+ ```
55
+
56
+ Then `bundle install`, or `gem install domain_sanity`.
57
+
58
+ ## Usage
59
+
60
+ ```ruby
61
+ require "domain_sanity"
62
+
63
+ DomainSanity.valid?("example.com") # => true
64
+ DomainSanity.valid?("www.example.co.uk") # => true
65
+ DomainSanity.valid?("münchen.de") # => true (IDN)
66
+ DomainSanity.valid?("example.123") # => false (all-numeric TLD)
67
+ DomainSanity.valid?("example.nope") # => false (unknown TLD)
68
+ DomainSanity.valid?("-bad.example.com") # => false (leading hyphen)
69
+
70
+ # Tell the user what went wrong. `reasons` returns structured Reason objects
71
+ # (code, message, offending label); each renders to its message as a string.
72
+ DomainSanity.reasons("-bad.example.123").map(&:message)
73
+ # => ["has an invalid label: \"-bad\"", "has an all-numeric TLD",
74
+ # "has a TLD that is not in the Public Suffix List"]
75
+ DomainSanity.reasons("-bad.example.123").map(&:code)
76
+ # => [:label_invalid, :numeric_tld, :unknown_tld]
77
+
78
+ # IP literals and reverse zones are not host names, so `valid?` rejects them.
79
+ DomainSanity.valid?("192.0.2.10") # => false (IP, not a name)
80
+ DomainSanity.valid?("1.2.0.192.in-addr.arpa") # => false (reverse zone)
81
+
82
+ # `valid_tld?` asks whether the argument is itself a public suffix (eTLD).
83
+ DomainSanity.valid_tld?("com") # => true
84
+ DomainSanity.valid_tld?("co.uk") # => true
85
+ DomainSanity.valid_tld?("example.com") # => false (a name, not a suffix)
86
+
87
+ # Wildcards, per the Baseline Requirements
88
+ DomainSanity.valid_wildcard?("*.example.com") # => true
89
+ DomainSanity.valid_wildcard?("*.co.uk") # => false (bare public suffix)
90
+ DomainSanity.valid_wildcard?("ba*.example.com") # => false (embedded)
91
+
92
+ # IP addresses and reverse zones
93
+ DomainSanity.ip?("192.0.2.10") # => true
94
+ DomainSanity.reserved_ip?("10.0.0.1") # => true (RFC 1918)
95
+ DomainSanity.public_ip?("1.1.1.1") # => true
96
+ DomainSanity.reverse_zone?("1.2.0.192.in-addr.arpa") # => true
97
+
98
+ # Registrable domain and public suffix
99
+ DomainSanity.registrable_domain("a.b.example.co.uk") # => "example.co.uk"
100
+ DomainSanity.public_suffix("a.b.example.co.uk") # => "co.uk"
101
+
102
+ # IDN round-tripping
103
+ DomainSanity.to_ascii("münchen.de") # => "xn--mnchen-3ya.de"
104
+ DomainSanity.to_unicode("xn--mnchen-3ya.de") # => "münchen.de"
105
+ ```
106
+
107
+ ### Analyze once, ask many times
108
+
109
+ When you have several questions about one name, `analyze` classifies it once and
110
+ returns a **typed subject** - a `Hostname`, `Wildcard`, `IPSubject`,
111
+ `ReverseZone`, or `MalformedSubject`. Facts are computed lazily and memoized, so
112
+ one question is cheap and many re-parse nothing:
113
+
114
+ ```ruby
115
+ r = DomainSanity.analyze("www.example.co.uk") # => DomainSanity::Hostname
116
+ r.valid? # => true
117
+ r.kind # => :hostname
118
+ r.registrable_domain # => "example.co.uk"
119
+ r.public_suffix # => "co.uk"
120
+ r.to_h # => { input:, kind:, valid:, registrable_domain:, ... }
121
+
122
+ ip = DomainSanity.analyze("10.0.0.1") # => DomainSanity::IPSubject
123
+ ip.reserved? # => true
124
+ ip.public? # => false
125
+ ip.registrable_domain # => NoMethodError - an IP has no registrable domain
126
+ ```
127
+
128
+ Methods that only make sense for one kind live only on that type, so you can't
129
+ ask an IP for its registrable domain. The entry point is `analyze` (not
130
+ `inspect`, which would shadow `Object#inspect`). `Subject#valid?` means exactly
131
+ what `DomainSanity.valid?` means: a structurally valid plain host name.
132
+ Wildcards, IPs, and reverse zones are not "valid" in that sense and expose their
133
+ own predicates (`valid_wildcard?`, `public?`).
134
+
135
+ While the *methods* are kind-specific, `to_h` is uniform: every subject
136
+ serializes the same key set, with `nil` where a fact does not apply to that kind,
137
+ so downstream `dig`/`present?` checks work the same for any input.
138
+
139
+ ## Policies
140
+
141
+ What counts as valid is a `Policy`. Pass `policy:` a preset symbol, a Hash of
142
+ overrides, or a `Policy` instance; the default is `:ca_baseline`.
143
+
144
+ ```ruby
145
+ DomainSanity.valid?("_dmarc.example.com") # => false
146
+ DomainSanity.valid?("_dmarc.example.com", policy: :dns_zone) # => true
147
+ DomainSanity.valid?("_dmarc.example.com", policy: { allow_underscore: true })
148
+ DomainSanity.valid?("intranet-host", policy: :dns_zone) # => true (single label)
149
+ DomainSanity.public_suffix("foo.github.io", policy: :dns_zone) # => "github.io"
150
+ ```
151
+
152
+ | Field | `:ca_baseline` | Meaning |
153
+ | --- | --- | --- |
154
+ | `allow_underscore` | `false` | permit `_` in labels (RFC 952/1123 forbid it) |
155
+ | `allow_single_label` | `false` | permit a bare host with no TLD (`intranet`) |
156
+ | `allow_trailing_dot` | `true` | accept & normalize one trailing root dot |
157
+ | `include_private_suffixes` | `false` | treat private PSL entries (`github.io`) as suffixes |
158
+ | `allow_reserved_tld` | `false` | accept special-use TLDs (`.test`, `.local`, `.onion`, `.internal`) |
159
+ | `require_single_script` | `false` | reject confusable mixed-script IDN labels |
160
+
161
+ Presets: `:ca_baseline` (strict, the default), `:dns_zone` (underscores,
162
+ single-label hosts, and private suffixes allowed), `:lenient` (all of that plus
163
+ RFC 6761/6762/7686 special-use TLDs).
164
+
165
+ `allow_reserved_tld` only affects validity - a reserved TLD is not in the Public
166
+ Suffix List, so `registrable_domain` / `public_suffix` stay `nil` for names like
167
+ `foo.test`.
168
+
169
+ ## IDN and homographs
170
+
171
+ IDN conversion (`to_ascii` / `to_unicode`) uses SimpleIDN, which is IDNA2003
172
+ punycode - **not** a full UTS-46 / IDNA2008 processor. Mapping of a few code
173
+ points (final sigma, ß, ZWJ/ZWNJ) differs from the current standard; normalize
174
+ upstream with a UTS-46 library if you need that exactness.
175
+
176
+ `mixed_script?` is a lightweight homograph guard that flags labels combining
177
+ scripts a Unicode registry would not allow (Latin with Cyrillic, etc.), while
178
+ permitting legitimate combinations like Japanese (`例え.jp`):
179
+
180
+ ```ruby
181
+ DomainSanity.mixed_script?("аpple.com") # => true (Cyrillic "а" + Latin)
182
+ DomainSanity.mixed_script?("münchen.de") # => false
183
+ DomainSanity.valid?("аpple.com", policy: { require_single_script: true }) # => false
184
+ ```
185
+
186
+ ## Notes
187
+
188
+ - **A single trailing dot is accepted** by default (`allow_trailing_dot`).
189
+ `example.com.` denotes the same name as `example.com`; a second trailing dot
190
+ (`example.com..`) is an empty label and is rejected.
191
+ - **`valid?` is consistent everywhere.** `DomainSanity.valid?(x)` and
192
+ `analyze(x).valid?` always agree: both mean "a structurally valid plain host
193
+ name," so wildcards, IPs, and reverse zones return `false`.
194
+ - **Data provenance.** `DomainSanity.data_versions` reports the reserved-IP
195
+ snapshot date and the PSL / IDNA gem versions; `rake data:check` fails when
196
+ the vendored IP snapshot goes stale.
197
+
198
+ ## What it checks
199
+
200
+ | Rule | Source |
201
+ | --- | --- |
202
+ | Label 1-63 octets, name <= 253 octets, <= 127 labels | RFC 1035 |
203
+ | Letter-digit-hyphen labels, no leading/trailing hyphen | RFC 952 / 1123 |
204
+ | TLD not all-numeric | RFC 3696 |
205
+ | TLD present in the ICANN Public Suffix List | Public Suffix List |
206
+ | IDN normalized before suffix checks | RFC 5890 / 5891 |
207
+ | Wildcard only as the whole leftmost label, never a bare suffix | CA/B Forum BR 3.2.2.6 |
208
+ | Private / reserved IP detection | RFC 1918 / 4193 / 6890 |
209
+ | Reverse-zone detection | RFC 1035 / 3596 |
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ bundle install
215
+ bundle exec rake spec # run the test suite
216
+ bundle exec standardrb # lint (Standard Ruby)
217
+ bundle exec rake data:check # verify the vendored reserved-IP snapshot is current
218
+ ```
219
+
220
+ The reserved-IP ranges are vendored by hand; `rake data:sync` prints the refresh
221
+ steps, and `rake data:check` (also run in CI) fails when the snapshot goes stale.
222
+
223
+ ## Contributing
224
+
225
+ 1. Fork it
226
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
227
+ 3. Commit your changes
228
+ 4. Push to the branch
229
+ 5. Create a Pull Request
230
+
231
+ Please make sure `bundle exec rake spec` and `bundle exec standardrb` both pass.
232
+
233
+ ## License
234
+
235
+ Available as open source under the [MIT License](LICENSE).
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "public_suffix"
5
+ require "simpleidn"
6
+
7
+ module DomainSanity
8
+ # A domain validator is only as correct as the reference data behind it, and
9
+ # that data drifts: IANA revises the special-purpose IP registries and the
10
+ # ICANN Public Suffix List changes constantly. Rather than let correctness rot
11
+ # silently, DomainSanity records where its data came from and how old it is,
12
+ # so callers can log it and CI can fail when it goes stale.
13
+ #
14
+ # The reserved-IP ranges in {IP} are vendored by hand; RESERVED_IP_SNAPSHOT is
15
+ # the date they were last reconciled with the IANA registries. The Public
16
+ # Suffix List and IDNA tables ship inside their respective gems, so their
17
+ # "version" is the gem version. Refresh the IP snapshot with `rake data:sync`
18
+ # (see the Rakefile) and bump the date below.
19
+ module Data
20
+ # Last time RESERVED_IPV4 / RESERVED_IPV6 were reconciled with
21
+ # https://www.iana.org/assignments/iana-ipv4-special-registry and its IPv6
22
+ # counterpart.
23
+ RESERVED_IP_SNAPSHOT = Date.new(2026, 7, 1)
24
+
25
+ # How old the IP snapshot may get before {stale?} reports true (~18 months).
26
+ MAX_SNAPSHOT_AGE_DAYS = 548
27
+
28
+ module_function
29
+
30
+ # A snapshot of every data source's provenance, safe to log or serialize.
31
+ def versions
32
+ {
33
+ reserved_ip_snapshot: RESERVED_IP_SNAPSHOT.iso8601,
34
+ reserved_ip_age_days: age_days,
35
+ public_suffix_gem: gem_version("PublicSuffix"),
36
+ simpleidn_gem: gem_version("SimpleIDN")
37
+ }
38
+ end
39
+
40
+ # True when the vendored IP snapshot is older than MAX_SNAPSHOT_AGE_DAYS.
41
+ def stale?(today = Date.today)
42
+ age_days(today) > MAX_SNAPSHOT_AGE_DAYS
43
+ end
44
+
45
+ def age_days(today = Date.today)
46
+ (today - RESERVED_IP_SNAPSHOT).to_i
47
+ end
48
+
49
+ def gem_version(mod_name)
50
+ mod = Object.const_get(mod_name)
51
+ mod.const_defined?(:VERSION) ? mod.const_get(:VERSION) : nil
52
+ rescue NameError
53
+ nil
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "simpleidn"
5
+
6
+ module DomainSanity
7
+ # Internationalized Domain Name helpers.
8
+ #
9
+ # Conversion between the ASCII (A-label / punycode) and Unicode (U-label)
10
+ # forms is delegated to SimpleIDN, which implements IDNA2003-style punycode.
11
+ # It is NOT a full UTS-46 / IDNA2008 processor: mapping of a handful of code
12
+ # points (final sigma, ß, ZWJ/ZWNJ, deviation characters) differs from the
13
+ # current standard. If you need IDNA2008 exactness, normalize upstream with a
14
+ # UTS-46 library before handing names to DomainSanity. Length and label
15
+ # validation here operate on the ASCII form regardless, so the wire-format
16
+ # limits stay correct either way.
17
+ #
18
+ # Separately, {mixed_script?} provides a lightweight homograph guard: it flags
19
+ # labels that combine scripts in ways a Unicode registry would not allow
20
+ # (e.g. Latin mixed with Cyrillic). This is opt-in via Policy
21
+ # (require_single_script) because legitimate names occasionally trip it.
22
+ #
23
+ # None of these helpers raise: unconvertible input comes back as nil so
24
+ # callers can branch cleanly. Only SimpleIDN::ConversionError (a malformed
25
+ # A-label) is swallowed.
26
+ module IDN
27
+ # Script groups that legitimately co-occur within one label, per the spirit
28
+ # of UTS-39 "Highly Restrictive": a run may mix Latin with one East Asian
29
+ # script family, but not, say, Latin with Cyrillic.
30
+ ALLOWED_MULTI_SCRIPT = [
31
+ Set[:latin, :han, :hiragana, :katakana], # Japanese
32
+ Set[:latin, :han, :bopomofo], # Chinese
33
+ Set[:latin, :han, :hangul] # Korean
34
+ ].freeze
35
+
36
+ # Scripts we can name. Anything letter-like outside this list collapses to
37
+ # :other, which still participates in the "more than one script" test.
38
+ SCRIPT_PATTERNS = {
39
+ latin: /\p{Latin}/,
40
+ greek: /\p{Greek}/,
41
+ cyrillic: /\p{Cyrillic}/,
42
+ armenian: /\p{Armenian}/,
43
+ hebrew: /\p{Hebrew}/,
44
+ arabic: /\p{Arabic}/,
45
+ han: /\p{Han}/,
46
+ hiragana: /\p{Hiragana}/,
47
+ katakana: /\p{Katakana}/,
48
+ hangul: /\p{Hangul}/,
49
+ bopomofo: /\p{Bopomofo}/,
50
+ thai: /\p{Thai}/,
51
+ devanagari: /\p{Devanagari}/
52
+ }.freeze
53
+
54
+ module_function
55
+
56
+ # Convert a name to its ASCII/punycode form. Returns nil for non-strings,
57
+ # empty input, or input that cannot be encoded.
58
+ def to_ascii(name)
59
+ return nil unless name.is_a?(String)
60
+ return nil if name.empty?
61
+
62
+ SimpleIDN.to_ascii(name)
63
+ rescue SimpleIDN::ConversionError
64
+ nil
65
+ end
66
+
67
+ # Convert a name to its Unicode form. Returns nil for non-strings, empty
68
+ # input, or input that cannot be decoded.
69
+ def to_unicode(name)
70
+ return nil unless name.is_a?(String)
71
+ return nil if name.empty?
72
+
73
+ SimpleIDN.to_unicode(name)
74
+ rescue SimpleIDN::ConversionError
75
+ nil
76
+ end
77
+
78
+ # True when the name (or any of its labels) uses the xn-- ACE prefix.
79
+ def punycode?(name)
80
+ return false unless name.is_a?(String)
81
+
82
+ name.downcase.split(".").any? { |label| label.start_with?("xn--") }
83
+ end
84
+
85
+ # The set of scripts among the letters of a string (Common/Inherited and
86
+ # non-letters are ignored). Unknown letters collapse to :other.
87
+ def scripts(string)
88
+ return Set.new unless string.is_a?(String)
89
+
90
+ string.each_char.with_object(Set.new) do |char, found|
91
+ next unless char.match?(/\p{L}/)
92
+
93
+ name, = SCRIPT_PATTERNS.find { |_, pattern| char.match?(pattern) }
94
+ found << (name || :other)
95
+ end
96
+ end
97
+
98
+ # True when any label in the name mixes scripts in a way that reads as a
99
+ # homograph risk. Operates on the Unicode form; an unconvertible name is not
100
+ # flagged (there is nothing to compare). Mixing is judged per label, since
101
+ # cross-label mixing (e.g. an xn-- label beside an ASCII one) is normal.
102
+ def mixed_script?(name)
103
+ mixed_script_unicode?(to_unicode(name) || name)
104
+ end
105
+
106
+ # As {mixed_script?}, but for a name already decoded to its Unicode form.
107
+ # Callers that hold the U-label form (Name.normalize does) skip re-decoding.
108
+ def mixed_script_unicode?(unicode)
109
+ return false unless unicode.is_a?(String)
110
+
111
+ unicode.split(".").any? { |label| label_mixed_script?(label) }
112
+ end
113
+
114
+ def label_mixed_script?(label)
115
+ found = scripts(label)
116
+ return false if found.size <= 1
117
+
118
+ ALLOWED_MULTI_SCRIPT.none? { |allowed| found.subset?(allowed) }
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module DomainSanity
6
+ # IP address helpers.
7
+ #
8
+ # A certificate authority has to tell three things apart: a public IP that
9
+ # may be certifiable, a private or otherwise reserved IP that the CA/Browser
10
+ # Forum Baseline Requirements forbid issuing for, and a reverse-DNS zone name
11
+ # (in-addr.arpa / ip6.arpa) that is not an issuable host name at all. These
12
+ # helpers answer all three from the public IANA special-purpose registries.
13
+ #
14
+ # Only single host addresses count as IPs here: prefix / CIDR notation such
15
+ # as "10.0.0.0/8" is deliberately rejected, since a network block is not a
16
+ # host name or a certificate subject.
17
+ module IP
18
+ # IPv4 special-purpose ranges. These are not globally routable public
19
+ # addresses and must not appear in a public certificate.
20
+ #
21
+ # Source: IANA IPv4 Special-Purpose Address Registry
22
+ # (https://www.iana.org/assignments/iana-ipv4-special-registry). This list
23
+ # is maintained by hand and should be re-synced with the registry when
24
+ # IANA adds or removes a block.
25
+ RESERVED_IPV4 = [
26
+ "0.0.0.0/8", # "This host on this network" (RFC 1122)
27
+ "10.0.0.0/8", # Private-use (RFC 1918)
28
+ "100.64.0.0/10", # Shared address space / CGN (RFC 6598)
29
+ "127.0.0.0/8", # Loopback (RFC 1122)
30
+ "169.254.0.0/16", # Link-local (RFC 3927)
31
+ "172.16.0.0/12", # Private-use (RFC 1918)
32
+ "192.0.0.0/24", # IETF protocol assignments (RFC 6890)
33
+ "192.0.2.0/24", # Documentation TEST-NET-1 (RFC 5737)
34
+ "192.31.196.0/24", # AS112-v4 (RFC 7535)
35
+ "192.52.193.0/24", # AMT (RFC 7450)
36
+ "192.88.99.0/24", # 6to4 relay anycast (RFC 3068, deprecated)
37
+ "192.168.0.0/16", # Private-use (RFC 1918)
38
+ "192.175.48.0/24", # Direct Delegation AS112 Service (RFC 7534)
39
+ "198.18.0.0/15", # Benchmarking (RFC 2544)
40
+ "198.51.100.0/24", # Documentation TEST-NET-2 (RFC 5737)
41
+ "203.0.113.0/24", # Documentation TEST-NET-3 (RFC 5737)
42
+ "224.0.0.0/4", # Multicast (RFC 5771)
43
+ "240.0.0.0/4", # Reserved for future use (RFC 1112)
44
+ "255.255.255.255/32" # Limited broadcast (RFC 8190)
45
+ ].freeze
46
+
47
+ # IPv6 special-purpose ranges.
48
+ #
49
+ # Source: IANA IPv6 Special-Purpose Address Registry
50
+ # (https://www.iana.org/assignments/iana-ipv6-special-registry). Re-sync
51
+ # with the registry when IANA adds or removes a block. Note that 2001::/23
52
+ # is a superset covering several individually registered protocol blocks
53
+ # (Teredo, benchmarking, AMT, ORCHIDv2, and friends).
54
+ RESERVED_IPV6 = [
55
+ "::/128", # Unspecified address (RFC 4291)
56
+ "::1/128", # Loopback (RFC 4291)
57
+ "::ffff:0:0/96", # IPv4-mapped (RFC 4291)
58
+ "64:ff9b::/96", # IPv4/IPv6 translation (RFC 6052)
59
+ "64:ff9b:1::/48", # Local-use IPv4/IPv6 translation (RFC 8215)
60
+ "100::/64", # Discard-only (RFC 6666)
61
+ "2001::/23", # IETF protocol assignments (RFC 2928)
62
+ "2001:db8::/32", # Documentation (RFC 3849)
63
+ "2002::/16", # 6to4 (RFC 3056, deprecated)
64
+ "2620:4f:8000::/48", # Direct Delegation AS112 Service (RFC 7534)
65
+ "3fff::/20", # Documentation (RFC 9637)
66
+ "5f00::/16", # Segment Routing (SRv6) SIDs (RFC 9602)
67
+ "fc00::/7", # Unique local addresses (RFC 4193)
68
+ "fe80::/10", # Link-local (RFC 4291)
69
+ "ff00::/8" # Multicast (RFC 4291)
70
+ ].freeze
71
+
72
+ # Parse the CIDR strings once so membership checks don't reparse them on
73
+ # every call.
74
+ RESERVED_IPV4_NETS = RESERVED_IPV4.map { |cidr| IPAddr.new(cidr) }.freeze
75
+ RESERVED_IPV6_NETS = RESERVED_IPV6.map { |cidr| IPAddr.new(cidr) }.freeze
76
+
77
+ # Characters that can appear in an IPv4 or IPv6 literal. Anything else means
78
+ # the string can't be an address, so we skip IPAddr.new (which signals a
79
+ # non-address by raising) - most host names contain a letter outside a-f and
80
+ # never reach that hot-path exception.
81
+ IP_CHARS = /\A[0-9a-f:.]+\z/i
82
+
83
+ module_function
84
+
85
+ # True when subject parses as a single IPv4 or IPv6 host address. Prefix /
86
+ # CIDR notation and non-strings return false.
87
+ def ip?(subject)
88
+ !parse(subject).nil?
89
+ end
90
+
91
+ # True when subject is a private or otherwise reserved (non-public) IP.
92
+ # Non-IP input returns false.
93
+ def reserved?(subject)
94
+ addr = parse(subject)
95
+ return false if addr.nil?
96
+
97
+ reserved_address?(addr)
98
+ end
99
+
100
+ # True when subject is a routable, non-reserved public IP. Non-IP input
101
+ # returns false. Parses once, unlike ip? && !reserved?.
102
+ def public?(subject)
103
+ addr = parse(subject)
104
+ return false if addr.nil?
105
+
106
+ !reserved_address?(addr)
107
+ end
108
+
109
+ # True when subject is a reverse-DNS zone name rather than a host name.
110
+ def reverse_zone?(subject)
111
+ return false unless subject.is_a?(String)
112
+
113
+ normalized = subject.downcase.chomp(".")
114
+ normalized == "in-addr.arpa" || normalized == "ip6.arpa" ||
115
+ normalized.end_with?(".in-addr.arpa") || normalized.end_with?(".ip6.arpa")
116
+ end
117
+
118
+ # Parse to an IPAddr for a single host address, or nil when the input is
119
+ # not one (including CIDR / prefix notation and non-strings).
120
+ def parse(subject)
121
+ return nil unless subject.is_a?(String)
122
+ return nil if subject.include?("/")
123
+ return nil unless subject.match?(IP_CHARS)
124
+
125
+ IPAddr.new(subject)
126
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
127
+ nil
128
+ end
129
+
130
+ # Whether an already-parsed IPAddr falls in any reserved range.
131
+ def reserved_address?(addr)
132
+ nets = addr.ipv4? ? RESERVED_IPV4_NETS : RESERVED_IPV6_NETS
133
+ nets.any? { |net| net.include?(addr) }
134
+ end
135
+ end
136
+ end