Botan 3.13.0
Crypto and TLS for C&
dns_name.cpp
Go to the documentation of this file.
1/*
2* (C) 2026 Jack Lloyd
3*
4* Botan is released under the Simplified BSD License (see license.txt)
5*/
6
7#include <botan/dns_name.h>
8
9#include <botan/exceptn.h>
10#include <botan/internal/parsing.h>
11#include <algorithm>
12
13namespace Botan {
14
15namespace {
16
17/*
18* Validate @p name as an RFC 1035 / 1123 DNS name and return its
19* lowercased canonical form. Throws Decoding_Error if @p name is not
20* a valid DNS name. A "*" label is accepted so SAN wildcard entries
21* round-trip through this validator unchanged.
22*/
23std::optional<std::string> check_and_canonicalize_dns_name(std::string_view name) {
24 /*
25 * RFC 1035 limits names to "255 octets or less", but that is in the wire
26 * encoding, which includes a length octet per label plus the root label.
27 * In presentation form (without a trailing dot) the limit is 253.
28 */
29 if(name.size() > 253) {
30 return {};
31 }
32
33 // DNS names are not empty
34 if(name.empty()) {
35 return {};
36 }
37
38 // DNS names do not start with or end with a dot
39 if(name.starts_with(".") || name.ends_with(".")) {
40 return {};
41 }
42
43 /*
44 * Table mapping uppercase to lowercase and only including values valid for
45 * DNS names: A-Z, a-z, 0-9, '-', '.', plus '*' for wildcarding (RFC 1035)
46 */
47 // clang-format off
48 constexpr uint8_t DNS_CHAR_MAPPING[128] = {
49 '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0',
50 '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0',
51 '\0', '\0', '\0', '\0', '*', '\0', '\0', '-', '.', '\0', '0', '1', '2', '3', '4', '5', '6', '7', '8',
52 '9', '\0', '\0', '\0', '\0', '\0', '\0', '\0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
53 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '\0', '\0', '\0', '\0',
54 '\0', '\0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q',
55 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '\0', '\0', '\0', '\0', '\0',
56 };
57 // clang-format on
58
59 std::string canon;
60 canon.reserve(name.size());
61
62 // RFC 1035: DNS labels must not exceed 63 characters
63 size_t current_label_length = 0;
64
65 // Tracks if the name consists only of digits and dots
66 bool all_numeric = true;
67
68 for(size_t i = 0; i != name.size(); ++i) {
69 const char c = name[i];
70
71 if(c == '.') {
72 // Sequential dot (.) characters are not allowed
73 if(i > 0 && name[i - 1] == '.') {
74 return {};
75 }
76
77 // Empty labels are not allowed
78 if(current_label_length == 0) {
79 return {};
80 }
81 current_label_length = 0; // Reset for next label
82 } else {
83 current_label_length++;
84
85 // Labels cannot exceed maximum DNS label length
86 if(current_label_length > 63) {
87 return {};
88 }
89 }
90
91 const uint8_t cu = static_cast<uint8_t>(c);
92 // DNS names are not allowed to include any high-bit set characters
93 if(cu >= 128) {
94 return {};
95 }
96 const uint8_t mapped = DNS_CHAR_MAPPING[cu];
97 // DNS names are from a restricted character set
98 if(mapped == 0) {
99 return {};
100 }
101
102 if(mapped != '.' && (mapped < '0' || mapped > '9')) {
103 all_numeric = false;
104 }
105
106 if(mapped == '-') {
107 // DNS labels are not allowed to include a leading or trailing hyphen
108 if(i == 0 || (i > 0 && name[i - 1] == '.')) {
109 return {}; // leading hyphen
110 }
111
112 if(i == name.size() - 1 || (i < name.size() - 1 && name[i + 1] == '.')) {
113 return {}; // trailing hyphen
114 }
115 }
116 canon.push_back(static_cast<char>(mapped));
117 }
118
119 // This should never be hit, due to earlier validation steps
120 if(current_label_length == 0) {
121 return {};
122 }
123
124 // An entirely numeric name ("1.2.3.4") is either a misplaced IP address
125 // or an attempt at confusing some other system; reject outright
126 if(all_numeric) {
127 return {};
128 }
129
130 return canon;
131}
132
133} // namespace
134
135//static
136std::optional<DNSName> DNSName::from_string(std::string_view name) {
137 if(auto canon = check_and_canonicalize_dns_name(name)) {
138 // TODO(C++23) std::string::contains
139 if(canon->find('*') != std::string::npos) {
140 return {};
141 }
142 return DNSName(std::move(*canon));
143 } else {
144 return {};
145 }
146}
147
148//static
149std::optional<DNSName> DNSName::from_san_string(std::string_view name) {
150 if(auto canon = check_and_canonicalize_dns_name(name)) {
151 /*
152 Validate the wildcard shape: at most one "*", and if present it must be in
153 the leftmost label (no "." before it). This matches the RFC 6125 6.4.3
154 form that host_wildcard_match accepts and rejects eg "*.*.example.com" or
155 "foo.*.example.com"
156 */
157 const auto first_star = canon->find('*');
158 if(first_star != std::string::npos) {
159 if(canon->find('*', first_star + 1) != std::string::npos) {
160 return std::nullopt;
161 }
162 const auto first_dot = canon->find('.');
163 if(first_dot != std::string::npos && first_dot < first_star) {
164 return std::nullopt;
165 }
166 /*
167 RFC 6125 6.4.3: "the client SHOULD NOT attempt to match a presented
168 identifier where the wildcard character is embedded within an
169 A-label or U-label"
170 */
171 if(canon->starts_with("xn--")) {
172 return std::nullopt;
173 }
174 // A wildcard match requires at least three labels, so shorter
175 // patterns ("*", "*.com") could never match any host
176 if(std::count(canon->begin(), canon->end(), '.') < 2) {
177 return std::nullopt;
178 }
179 }
180 return DNSName(std::move(*canon));
181 } else {
182 return {};
183 }
184}
185
186bool DNSName::matches_wildcard(std::string_view wildcard) const {
187 return host_wildcard_match(wildcard, m_name);
188}
189
190//static
191bool DNSName::host_wildcard_match(std::string_view issued, std::string_view host) {
192 if(host.empty() || issued.empty()) {
193 return false;
194 }
195
196 // Maximum valid DNS name
197 if(host.size() > 253) {
198 return false;
199 }
200
201 /*
202 The wildcard if existing absorbs (host.size() - issued.size() + 1) chars,
203 which must be non-negative. So issued cannot possibly exceed host.size() + 1.
204 */
205 if(issued.size() > host.size() + 1) {
206 return false;
207 }
208
209 /*
210 If there are embedded nulls in your issued name
211 Well I feel bad for you son
212 */
213 if(issued.find('\0') != std::string_view::npos || host.find('\0') != std::string_view::npos) {
214 return false;
215 }
216
217 // '*' is not a valid character in DNS names so should not appear on the host side
218 if(host.find('*') != std::string_view::npos) {
219 return false;
220 }
221
222 // Similarly a DNS name can't end in .
223 if(host.back() == '.') {
224 return false;
225 }
226
227 // Nor can it start with one
228 if(host.front() == '.') {
229 return false;
230 }
231
232 // And a host can't have an empty name component, so reject that
233 if(host.find("..") != std::string_view::npos) {
234 return false;
235 }
236
237 // ASCII-only case-insensitive char equality, avoids locale overhead from tolower
238 auto dns_char_eq = [](char a, char b) -> bool {
239 if(a == b) {
240 return true;
241 }
242 const auto la = static_cast<unsigned char>(a | 0x20);
243 const auto lb = static_cast<unsigned char>(b | 0x20);
244 return la == lb && la >= 'a' && la <= 'z';
245 };
246
247 auto dns_char_eq_range = [&](std::string_view a, std::string_view b) -> bool {
248 if(a.size() != b.size()) {
249 return false;
250 }
251 for(size_t i = 0; i != a.size(); ++i) {
252 if(!dns_char_eq(a[i], b[i])) {
253 return false;
254 }
255 }
256 return true;
257 };
258
259 // Exact match: accept
260 if(dns_char_eq_range(issued, host)) {
261 return true;
262 }
263
264 // First detect offset of wildcard '*' if included
265 const size_t first_star = issued.find('*');
266 const bool has_wildcard = (first_star != std::string_view::npos);
267
268 // At most one wildcard is allowed
269 if(has_wildcard && issued.find('*', first_star + 1) != std::string_view::npos) {
270 return false;
271 }
272
273 // If no * at all then not a wildcard, and so not a match
274 if(!has_wildcard) {
275 return false;
276 }
277
278 /*
279 RFC 6125 6.4.3: "the client SHOULD NOT attempt to match a presented
280 identifier where the wildcard character is embedded within an
281 A-label or U-label"
282
283 The host side check rejects a partial wildcard absorbing part of an
284 A-label of the host, which would otherwise allow the same confusion.
285 */
286 const auto is_idna_prefixed = [&](std::string_view label) {
287 return label.size() >= 4 && dns_char_eq_range(label.substr(0, 4), "xn--");
288 };
289 const auto issued_label = issued.substr(0, issued.find('.'));
290 if(is_idna_prefixed(issued_label)) {
291 return false;
292 }
293 if(issued_label != "*" && is_idna_prefixed(host.substr(0, host.find('.')))) {
294 return false;
295 }
296
297 /*
298 Now walk through the issued string, making sure every character
299 matches. When we come to the (singular) '*', jump forward in the
300 hostname by the corresponding amount. We know exactly how much
301 space the wildcard takes because it must be exactly `len(host) -
302 len(issued) + 1 chars`.
303
304 We also verify that the '*' comes in the leftmost component, and
305 doesn't skip over any '.' in the hostname.
306 */
307 size_t dots_seen = 0;
308 size_t host_idx = 0;
309
310 for(size_t i = 0; i != issued.size(); ++i) {
311 if(issued[i] == '.') {
312 dots_seen += 1;
313 }
314
315 if(issued[i] == '*') {
316 // Fail: wildcard can only come in leftmost component
317 if(dots_seen > 0) {
318 return false;
319 }
320
321 /*
322 Since there is only one * we know the tail of the issued and
323 hostname must be an exact match. In this case advance host_idx
324 to match.
325 */
326 const size_t advance = (host.size() - issued.size() + 1);
327
328 if(host_idx + advance > host.size()) { // shouldn't happen
329 return false;
330 }
331
332 // Can't be any intervening .s that we would have skipped
333 for(size_t k = host_idx; k != host_idx + advance; ++k) {
334 if(host[k] == '.') {
335 return false;
336 }
337 }
338
339 host_idx += advance;
340 } else {
341 if(!dns_char_eq(issued[i], host[host_idx])) {
342 return false;
343 }
344
345 host_idx += 1;
346 }
347 }
348
349 // Wildcard issued name must have at least 3 components
350 if(dots_seen < 2) {
351 return false;
352 }
353
354 return true;
355}
356
357} // namespace Botan
bool matches_wildcard(std::string_view wildcard) const
Definition dns_name.cpp:186
static bool host_wildcard_match(std::string_view issued, std::string_view host)
Definition dns_name.cpp:191
static std::optional< DNSName > from_san_string(std::string_view name)
Definition dns_name.cpp:149
static std::optional< DNSName > from_string(std::string_view name)
Definition dns_name.cpp:136
const std::string & name() const
Definition dns_name.h:57