• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

randombit / botan / 26076563351

18 May 2026 04:08PM UTC coverage: 89.334% (+0.02%) from 89.316%
26076563351

push

github

web-flow
Merge pull request #5598 from randombit/jack/x509-nameconstraints-uri-email

Add URI and email name constraint processing

108478 of 121430 relevant lines covered (89.33%)

11163868.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.55
/src/tests/test_name_constraint.cpp
1
/*
2
* (C) 2015,2016 Kai Michaelis
3
*     2026 Jack Lloyd
4
*
5
* Botan is released under the Simplified BSD License (see license.txt)
6
*/
7

8
#include "tests.h"
9

10
#if defined(BOTAN_HAS_X509_CERTIFICATES)
11
   #include <botan/ber_dec.h>
12
   #include <botan/data_src.h>
13
   #include <botan/pkix_types.h>
14
   #include <botan/x509cert.h>
15
   #include <botan/x509path.h>
16
   #include <botan/internal/calendar.h>
17
   #include <botan/internal/x509_utils.h>
18
   #include <algorithm>
19
   #include <fstream>
20
#endif
21

22
namespace Botan_Tests {
23

24
namespace {
25

26
#if defined(BOTAN_HAS_X509_CERTIFICATES) && defined(BOTAN_HAS_ECDSA) && defined(BOTAN_HAS_SHA2_32) && \
27
   defined(BOTAN_TARGET_OS_HAS_FILESYSTEM)
28

29
class Name_Constraint_Validation_Tests final : public Test {
1✔
30
   public:
31
      std::vector<Test::Result> run() override {
1✔
32
         std::vector<Test::Result> results;
1✔
33

34
         /*
35
         * Each test is a single PEM file containing the chain leaf-first (leaf,
36
         * intermediates...), the trust anchor is shared as root.pem, and expected.txt
37
         * maps test-name to the Path_Validation_Result::result_string() output.
38
         */
39
         const std::string base = "x509/name_constraints/";
1✔
40
         const auto expected = read_manifest(Test::data_file(base + "expected.txt"));
2✔
41

42
         const Botan::X509_Certificate trust_anchor(Test::data_file(base + "root.pem"));
2✔
43

44
         const auto when = Botan::calendar_point(2027, 1, 1, 0, 0, 0).to_std_timepoint();
1✔
45

46
         const Botan::Path_Validation_Restrictions restrictions(false, 128);
2✔
47

48
         for(const auto& [name, expected_result] : expected) {
228✔
49
            Test::Result result("Name constraints test " + name);
227✔
50

51
            Botan::Certificate_Store_In_Memory store;
227✔
52
            store.add_certificate(trust_anchor);
227✔
53

54
            const auto chain = load_chain(Test::data_file(base + name + ".pem"));
681✔
55
            if(chain.empty()) {
227✔
56
               result.test_failure("No certs found in " + name + ".pem");
×
57
               results.emplace_back(std::move(result));
×
58
               continue;
×
59
            }
60

61
            const std::string hostname;
227✔
62

63
            const auto pv =
227✔
64
               Botan::x509_path_validate(chain, restrictions, store, hostname, Botan::Usage_Type::UNSPECIFIED, when);
227✔
65

66
            result.test_str_eq("validation result", pv.result_string(), expected_result);
227✔
67
            results.emplace_back(std::move(result));
227✔
68
         }
227✔
69

70
         return results;
2✔
71
      }
1✔
72

73
   private:
74
      // Read all certificates from a PEM bundle in file order (leaf first).
75
      static std::vector<Botan::X509_Certificate> load_chain(const std::string& filename) {
227✔
76
         Botan::DataSource_Stream in(filename);
227✔
77
         std::vector<Botan::X509_Certificate> certs;
227✔
78
         while(!in.end_of_data()) {
704✔
79
            try {
704✔
80
               certs.emplace_back(in);
704✔
81
            } catch(const Botan::Decoding_Error&) {
227✔
82
               break;
227✔
83
            }
227✔
84
         }
85
         return certs;
227✔
86
      }
227✔
87

88
      // Parse `<chain-name>:<result>` lines; ignore blanks and `#` comments.
89
      static std::vector<std::pair<std::string, std::string>> read_manifest(const std::string& path) {
1✔
90
         std::vector<std::pair<std::string, std::string>> out;
1✔
91
         std::ifstream in(path);
1✔
92
         std::string line;
1✔
93
         while(std::getline(in, line)) {
232✔
94
            if(line.empty() || line.front() == '#') {
231✔
95
               continue;
4✔
96
            }
97
            const auto colon = line.find(':');
227✔
98
            if(colon == std::string::npos) {
227✔
99
               continue;
×
100
            }
101
            out.emplace_back(line.substr(0, colon), line.substr(colon + 1));
454✔
102
         }
103
         return out;
2✔
104
      }
1✔
105
};
106

107
BOTAN_REGISTER_TEST("x509", "x509_name_constraints", Name_Constraint_Validation_Tests);
108

109
/*
110
* Validate that GeneralName iPAddress decoding rejects masks that are not a
111
* contiguous CIDR prefix. Drives the decoder with hand-rolled BER for a
112
* single [7] IMPLICIT OCTET STRING carrying {net || mask}.
113
*/
114
class Name_Constraint_IP_Mask_Tests final : public Text_Based_Test {
×
115
   public:
116
      Name_Constraint_IP_Mask_Tests() : Text_Based_Test("x509/general_name_ip.vec", "Address,Netmask") {}
2✔
117

118
      Test::Result run_one_test(const std::string& header, const VarMap& vars) override {
19✔
119
         Test::Result result("GeneralName iPAddress mask validation");
19✔
120

121
         const auto address = vars.get_req_bin("Address");
19✔
122
         const auto netmask = vars.get_req_bin("Netmask");
19✔
123

124
         const auto der = encode_address(address, netmask);
19✔
125

126
         Botan::BER_Decoder decoder(der, Botan::BER_Decoder::Limits::DER());
19✔
127
         Botan::GeneralName gn;
19✔
128

129
         if(header == "Valid") {
19✔
130
            try {
12✔
131
               gn.decode_from(decoder);
12✔
132
               result.test_success("Accepted valid GeneralName IP encoding");
12✔
133
            } catch(Botan::Decoding_Error&) {
×
134
               result.test_failure("Rejected valid GeneralName IP encoding");
×
135
            }
×
136
         } else {
137
            try {
7✔
138
               gn.decode_from(decoder);
7✔
139
               result.test_failure("Accepted invalid GeneralName IP encoding");
×
140
            } catch(Botan::Decoding_Error&) {
7✔
141
               result.test_success("Rejected invalid GeneralName IP encoding");
7✔
142
            }
7✔
143
         }
144

145
         return result;
38✔
146
      }
19✔
147

148
   private:
149
      static std::vector<uint8_t> encode_address(std::span<const uint8_t> address, std::span<const uint8_t> netmask) {
19✔
150
         std::vector<uint8_t> der;
19✔
151
         // [7] IMPLICIT OCTET STRING, primitive, context-specific.
152
         der.push_back(0x87);
19✔
153
         // Short for length is sufficient here
154
         der.push_back(static_cast<uint8_t>(address.size() + netmask.size()));
19✔
155
         der.insert(der.end(), address.begin(), address.end());
19✔
156
         der.insert(der.end(), netmask.begin(), netmask.end());
19✔
157
         return der;
19✔
158
      }
×
159
};
160

161
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_ip_mask", Name_Constraint_IP_Mask_Tests);
162

163
/*
164
* Strict validation at the constraint-factory boundary: malformed
165
* inputs throw, valid inputs are canonicalized (lowercase host,
166
* preserve email local-part case).
167
*/
168
class Name_Constraint_Factory_Validation_Tests final : public Test {
1✔
169
   private:
170
      using FactoryFn = Botan::GeneralName (*)(std::string_view);
171

172
      static void check_valid(Test::Result& result,
20✔
173
                              const std::string& label,
174
                              FactoryFn make,
175
                              std::string_view input,
176
                              std::string_view expected_name) {
177
         try {
20✔
178
            const auto gn = make(input);
20✔
179
            result.test_str_eq(label + " canonical: " + std::string(input), gn.name(), expected_name);
60✔
180
         } catch(const std::exception& e) {
20✔
181
            result.test_failure(label + " rejected valid '" + std::string(input) + "': " + e.what());
×
182
         }
×
183
      }
20✔
184

185
      static void check_invalid(Test::Result& result,
42✔
186
                                const std::string& label,
187
                                FactoryFn make,
188
                                std::string_view input) {
189
         try {
42✔
190
            (void)make(input);
42✔
191
            result.test_failure(label + " accepted invalid '" + std::string(input) + "'");
×
192
         } catch(const Botan::Invalid_Argument&) {
42✔
193
            result.test_success(label + " rejected '" + std::string(input) + "'");
168✔
194
         }
42✔
195
      }
42✔
196

197
      static Test::Result test_dns() {
1✔
198
         Test::Result result("X509v3 Name Constraints: DNS factory validation");
1✔
199
         const auto m = &Botan::GeneralName::dns;
1✔
200
         check_valid(result, "DNS", m, "example.com", "example.com");
1✔
201
         check_valid(result, "DNS", m, "EXAMPLE.com", "example.com");
1✔
202
         check_valid(result, "DNS", m, "host", "host");
1✔
203
         check_valid(result, "DNS", m, ".example.com", ".example.com");
1✔
204

205
         const auto rejected = {"",
1✔
206
                                ".",
207
                                "..example.com",
208
                                "example..com",
209
                                "example.com.",
210
                                "*.example.com",
211
                                "host name",
212
                                " example.com",
213
                                "example.com ",
214
                                "_acme-challenge.example.com"};
1✔
215

216
         for(const auto& bad : rejected) {
11✔
217
            check_invalid(result, "DNS", m, bad);
20✔
218
         }
219
         return result;
1✔
220
      }
×
221

222
      static Test::Result test_uri() {
1✔
223
         Test::Result result("X509v3 Name Constraints: URI factory validation");
1✔
224
         const auto m = &Botan::GeneralName::uri;
1✔
225
         check_valid(result, "URI", m, "example.com", "example.com");
1✔
226
         check_valid(result, "URI", m, ".example.com", ".example.com");
1✔
227
         check_valid(result, "URI", m, "EXAMPLE.com", "example.com");
1✔
228
         // RFC 5280 4.2.1.10: "The constraint MUST be specified as a
229
         // fully qualified domain name". Single-label hosts and full
230
         // URIs are not constraint-shaped; both are rejected.
231
         for(const auto& bad : {"",
21✔
232
                                ".",
233
                                "localhost",
234
                                ".localhost",
235
                                "https://example.com",
236
                                "https://example.com/path",
237
                                "example.com:443",
238
                                "*.example.com",
239
                                "example.com.",
240
                                "..example.com"}) {
11✔
241
            check_invalid(result, "URI", m, bad);
20✔
242
         }
243
         return result;
1✔
244
      }
×
245

246
      static Test::Result test_uri_san_value() {
1✔
247
         Test::Result result("X509v3 Name Constraints: URI SAN value factory validation");
1✔
248
         const auto m = &Botan::GeneralName::_uri_san_value;
1✔
249
         check_valid(result, "URI SAN", m, "https://example.com", "https://example.com");
1✔
250
         check_valid(result, "URI SAN", m, "https://example.com/path?q=1#frag", "https://example.com/path?q=1#frag");
1✔
251
         check_valid(result, "URI SAN", m, "HTTPS://Example.COM/", "HTTPS://Example.COM/");
1✔
252
         check_valid(result, "URI SAN", m, "https://localhost/", "https://localhost/");
1✔
253
         // Inputs URI::parse rejects (RFC 3986 syntax violations,
254
         // constraint-shape values that aren't URIs).
255
         for(const auto& bad : {"",
17✔
256
                                "example.com",
257
                                ".example.com",
258
                                "not a uri",
259
                                "://no.scheme/",
260
                                "https://example.com/has space",
261
                                "https://user@bad@example.com/",
262
                                "https://example.com/%G0"}) {
9✔
263
            check_invalid(result, "URI SAN", m, bad);
16✔
264
         }
265
         return result;
1✔
266
      }
×
267

268
      static Test::Result test_dns_san_value() {
1✔
269
         Test::Result result("X509v3 Name Constraints: DNS SAN value factory validation");
1✔
270
         const auto m = &Botan::GeneralName::_dns_san_value;
1✔
271

272
         check_valid(result, "DNS SAN", m, "example.com", "example.com");
1✔
273
         check_valid(result, "DNS SAN", m, "EXAMPLE.com", "example.com");
1✔
274
         check_valid(result, "DNS SAN", m, "*.example.com", "*.example.com");
1✔
275
         check_valid(result, "DNS SAN", m, "foo*.example.com", "foo*.example.com");
1✔
276
         check_valid(result, "DNS SAN", m, "*bar.example.com", "*bar.example.com");
1✔
277

278
         for(const auto& bad : {"", ".", "..example.com", "*.*.example.com", "foo.*.example.com", "host name"}) {
7✔
279
            check_invalid(result, "DNS SAN", m, bad);
12✔
280
         }
281
         return result;
1✔
282
      }
×
283

284
      static Test::Result test_email() {
1✔
285
         Test::Result result("X509v3 Name Constraints: email factory validation");
1✔
286
         const auto m = &Botan::GeneralName::email;
1✔
287
         // Mailbox form: local-part case-preserved, host lowercased (RFC 5280 7.5).
288
         check_valid(result, "Email", m, "Alice@Example.COM", "Alice@example.com");
1✔
289
         check_valid(result, "Email", m, "user@example.com", "user@example.com");
1✔
290
         // Host form: bare DNS name.
291
         check_valid(result, "Email", m, "example.com", "example.com");
1✔
292
         // Subtree form: leading dot is preserved.
293
         check_valid(result, "Email", m, ".example.com", ".example.com");
1✔
294
         for(const auto& bad : {"",
17✔
295
                                "@example.com",
296
                                "user@",
297
                                "a@b@c",
298
                                ".",
299
                                "user@example..com",
300
                                "user@.example.com",
301
                                "user@*.example.com"}) {
9✔
302
            check_invalid(result, "Email", m, bad);
16✔
303
         }
304
         return result;
1✔
305
      }
×
306

307
   public:
308
      std::vector<Test::Result> run() override {
1✔
309
         return {test_dns(), test_uri(), test_email(), test_uri_san_value(), test_dns_san_value()};
6✔
310
      }
1✔
311
};
312

313
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_factory_validation", Name_Constraint_Factory_Validation_Tests);
314

315
class Wildcard_Excluded_Subtree_Containment_Tests final : public Test {
1✔
316
   public:
317
      std::vector<Test::Result> run() override {
1✔
318
         Test::Result result("X509v3 Name Constraints: wildcard SAN vs excluded DNS subtree");
1✔
319

320
         struct Case {
28✔
321
               std::string pattern;     // SAN wildcard
322
               std::string constraint;  // excluded DNS constraint value
323
               bool expect_intersect;
324
         };
325

326
         const std::vector<Case> cases = {
1✔
327
            // SAN of *.com can expand to evil.com.
328
            {"*.com", "evil.com", true},
1✔
329
            // Leading-dot subtree: *.com can expand to <anything>.com.
330
            {"*.com", ".com", true},
331
            // Wildcard whose tail equals the constraint: every expansion
332
            // is in the subtree.
333
            {"*.example.com", "example.com", true},
334
            {"*.example.com", ".example.com", true},
335
            // Wildcard with extra labels under the constraint: every
336
            // expansion is in the subtree.
337
            {"*.foo.example.com", "example.com", true},
338
            // Partial wildcards in the leftmost label that absorb the
339
            // missing labels of the constraint base.
340
            {"foo*.example.com", "example.com", true},
341
            {"*bar.example.com", "example.com", true},
342
            // Non-overlapping suffixes: no expansion in subtree.
343
            {"*.example.com", "evil.com", false},
344
            {"*.example.com", ".other.com", false},
345
            // Wildcard tail shorter than constraint, and leftover prefix
346
            // contains a dot - can't be produced by a single-label wildcard.
347
            {"*.com", "evil.example.com", false},
348
            // Single-label wildcards only match single-label hosts.
349
            {"*", "evil.com", false},
350
            {"*", "com", true},
351
            {"foo*", "foobar", true},
352
            // Leading-dot subtree excludes the apex; single-label wildcard
353
            // can't reach into it.
354
            {"*", ".com", false},
355
         };
15✔
356

357
         for(const auto& c : cases) {
15✔
358
            const bool got = Botan::wildcard_intersects_excluded_dns_subtree(c.pattern, c.constraint);
14✔
359
            result.test_bool_eq(c.pattern + " vs " + c.constraint, got, c.expect_intersect);
42✔
360
         }
361

362
         return {result};
3✔
363
      }
4✔
364
};
365

366
BOTAN_REGISTER_TEST("x509",
367
                    "x509_name_constraint_wildcard_excluded_containment",
368
                    Wildcard_Excluded_Subtree_Containment_Tests);
369

370
class SmtpUTF8Mailbox_Constraint_Match_Tests final : public Test {
1✔
371
   public:
372
      std::vector<Test::Result> run() override {
1✔
373
         Test::Result result("X509v3 Name Constraints: rfc822Name matches SmtpUTF8Mailbox");
1✔
374

375
         // RFC 9598 Section 6: rfc822Name constraints extend to SmtpUTF8Mailbox
376
         // SAN entries. The constraint's local-part (if any) is
377
         // ignored; comparison is on the domain part.
378

379
         const auto host_constraint = Botan::GeneralName::email("example.com");
1✔
380
         const auto subtree_constraint = Botan::GeneralName::email(".example.com");
1✔
381
         const auto mailbox_constraint = Botan::GeneralName::email("alice@example.com");
1✔
382

383
         const auto mailbox = [](std::string_view s) { return Botan::SmtpUtf8Mailbox::from_string(s).value(); };
30✔
384

385
         // Host constraint: domain must match exactly.
386
         result.test_is_true("host constraint matches identical domain",
2✔
387
                             host_constraint.matches_email(mailbox("user@example.com")));
1✔
388
         result.test_is_false("host constraint rejects subdomain",
2✔
389
                              host_constraint.matches_email(mailbox("user@sub.example.com")));
1✔
390
         result.test_is_false("host constraint rejects unrelated domain",
2✔
391
                              host_constraint.matches_email(mailbox("user@evil.com")));
1✔
392

393
         // Subtree constraint (leading dot): proper subdomains match,
394
         // base does not.
395
         result.test_is_true("subtree constraint matches subdomain",
2✔
396
                             subtree_constraint.matches_email(mailbox("user@sub.example.com")));
1✔
397
         result.test_is_false("subtree constraint rejects apex",
2✔
398
                              subtree_constraint.matches_email(mailbox("user@example.com")));
1✔
399
         result.test_is_false("subtree constraint rejects unrelated domain",
2✔
400
                              subtree_constraint.matches_email(mailbox("user@evil.com")));
1✔
401

402
         // RFC 9549 deprecates mailbox-form rfc822Name constraints for
403
         // SmtpUTF8Mailbox matching: such constraints must not match.
404
         result.test_is_false("mailbox constraint does not apply to SmtpUTF8Mailbox",
2✔
405
                              mailbox_constraint.matches_email(mailbox("alice@example.com")));
1✔
406

407
         // The reviewer's bypass: a CA permitted to ".example.com" issues
408
         // a leaf with SmtpUTF8Mailbox "alice@evil.com". Before the fix
409
         // this would slip through; after, the matcher correctly reports
410
         // no match, and is_excluded/is_permitted will reject the chain.
411
         result.test_is_false("bypass closed: ASCII evil.com against .example.com",
2✔
412
                              subtree_constraint.matches_email(mailbox("alice@evil.com")));
1✔
413
         result.test_is_false("bypass closed: UTF-8 local part doesn't change the answer",
2✔
414
                              subtree_constraint.matches_email(mailbox("\xCE\xB4\xCE\xBF\xCE\xBA\xCE\xB9@evil.com")));
1✔
415

416
         // Non-email constraints don't match regardless of mailbox.
417
         const auto dns_constraint = Botan::GeneralName::dns("example.com");
1✔
418
         result.test_is_false("DNS constraint doesn't match SmtpUTF8Mailbox",
2✔
419
                              dns_constraint.matches_email(mailbox("user@example.com")));
1✔
420

421
         // SmtpUtf8Mailbox::from_string rejects the malformed shapes
422
         // we previously had to guard against in the matcher.
423
         for(const auto& bad : {"",
23✔
424
                                "no-at-sign.example.com",
425
                                "@example.com",
426
                                "alice@",
427
                                "a@b@c",
428
                                "alice@.example.com",
429
                                "alice@example..com",
430
                                "alice..bob@example.com",
431
                                ".alice@example.com",
432
                                // RFC 9598 Section 3: non-ASCII domain labels
433
                                // MUST be in A-label form on the wire.
434
                                // Raw UTF-8 in the domain is rejected.
435
                                "alice@\xD0\xBF\xD1\x80\xD0\xB8\xD0\xBC\xD0\xB5\xD1\x80.\xD1\x80\xD1\x84",
436
                                // Invalid UTF-8 anywhere in the input.
437
                                "alice@\xC0\xC0.com"}) {
12✔
438
            result.test_is_false("SmtpUtf8Mailbox rejects malformed: " + std::string(bad),
22✔
439
                                 Botan::SmtpUtf8Mailbox::from_string(bad).has_value());
22✔
440
         }
441

442
         // ASCII and UTF-8-local-part mailboxes both parse.
443
         result.test_is_true("ASCII mailbox parses",
2✔
444
                             Botan::SmtpUtf8Mailbox::from_string("alice@example.com").has_value());
1✔
445
         result.test_is_true(
2✔
446
            "UTF-8 local part parses",
447
            Botan::SmtpUtf8Mailbox::from_string("\xCE\xB4\xCE\xBF\xCE\xBA\xCE\xB9@example.com").has_value());
1✔
448
         // A-label encoded IDN domain parses (RFC 9598 Section 3 mandates this
449
         // form for any label containing non-ASCII characters).
450
         result.test_is_true("A-label IDN domain parses",
2✔
451
                             Botan::SmtpUtf8Mailbox::from_string("alice@xn--e1afmkfd.xn--p1ai").has_value());
1✔
452

453
         return {result};
3✔
454
      }
5✔
455
};
456

457
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_smtp_utf8_match", SmtpUTF8Mailbox_Constraint_Match_Tests);
458

459
#endif
460

461
}  // namespace
462

463
}  // namespace Botan_Tests
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc