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
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
|
+
[](https://github.com/msuliq/domain_sanity/actions/workflows/pull_request.yml)
|
|
4
|
+
[](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
|