From 6733e4997dcfd45b2dd0aa0235c27dbec5f34e3b Mon Sep 17 00:00:00 2001 From: Daniel Pouzzner Date: Tue, 21 Apr 2026 16:56:08 -0500 Subject: [PATCH] wolfcrypt/src/asn.c, wolfssl/wolfcrypt/asn.h: add wolfssl_local_IsValidFQDN() tests/api/test_ossl_x509.c, tests/api/test_ossl_x509.h: add test_wolfssl_local_IsValidFQDN(). src/internal.c: in MatchDomainName(), when WOLFSSL_LEFT_MOST_WILDCARD_ONLY, do pattern matching and case folding only if target string validates as an FQDN. --- src/internal.c | 16 +++++++ tests/api/test_ossl_x509.c | 97 ++++++++++++++++++++++++++++++++++++++ tests/api/test_ossl_x509.h | 2 + wolfcrypt/src/asn.c | 75 +++++++++++++++++++++++++++++ wolfssl/wolfcrypt/asn.h | 5 ++ 5 files changed, 195 insertions(+) diff --git a/src/internal.c b/src/internal.c index 2ba6cabc157..33d6932f7c4 100644 --- a/src/internal.c +++ b/src/internal.c @@ -13334,6 +13334,22 @@ int MatchDomainName(const char* pattern, int patternLen, const char* str, return 1; #endif + if (leftWildcardOnly && (! wolfssl_local_IsValidFQDN(str, strLen))) { + /* Not a valid FQDN -- require byte-exact match, no case folding, no + * wildcard interpretation. This is appropriate for an IPv4 match, for + * example, but also matches improvised names like "localhost", albeit + * case-sensitively. + */ + return (((word32)patternLen == strLen) && + (XMEMCMP(pattern, str, patternLen) == 0)); + } + + /* strip trailing dots if necessary (FQDN designator). */ + if (str[strLen-1] == '.') + --strLen; + if (pattern[patternLen-1] == '.') + --patternLen; + while (patternLen > 0) { /* Get the next pattern char to evaluate */ char p = (char)XTOLOWER((unsigned char)*pattern); diff --git a/tests/api/test_ossl_x509.c b/tests/api/test_ossl_x509.c index ce8546dc247..1244a0869fd 100644 --- a/tests/api/test_ossl_x509.c +++ b/tests/api/test_ossl_x509.c @@ -1081,11 +1081,19 @@ int test_wolfSSL_X509_check_ip_asc(void) ExpectIntEQ(wolfSSL_X509_check_ip_asc(cn_lit, "127.0.0.1", 0), 0); /* CN=*.0.0.1 with no SAN must NOT wildcard-match "127.0.0.1". */ ExpectIntEQ(wolfSSL_X509_check_ip_asc(cn_wild, "127.0.0.1", 0), 0); + /* CN-based hostname matching must still work for hostname checks * (sanity check that the fix didn't over-correct). */ ExpectIntEQ(wolfSSL_X509_check_host(cn_wild, "1.0.0.1", XSTRLEN("1.0.0.1"), 0, NULL), 1); + /* However, when WOLFSSL_LEFT_MOST_WILDCARD_ONLY, CN-based hostname + * matching must not apply wildcards when the supplied hostname isn't a + * well-formed FQDN. + */ + ExpectIntEQ(wolfSSL_X509_check_host(cn_wild, "1.0.0.1", + XSTRLEN("1.0.0.1"), WOLFSSL_LEFT_MOST_WILDCARD_ONLY, NULL), 0); + wolfSSL_X509_free(cn_wild); wolfSSL_X509_free(cn_lit); } @@ -1610,6 +1618,95 @@ int test_wolfSSL_X509_name_match3(void) return EXPECT_RESULT(); } +int test_wolfssl_local_IsValidFQDN(void) { + EXPECT_DECLS; +#if !defined(NO_ASN) && !defined(WOLFCRYPT_ONLY) && !defined(NO_CERTS) + static const struct { const char *str; int is_FQDN; } test_cases[] = { + {"example.com", 1}, + {"example.com.", 1}, /* trailing dot (absolute form) */ + {"sub.example.com", 1}, + {"a.b", 1}, /* minimal two-label */ + {"xn--nxasmq5b.com", 1}, /* punycode / IDN (ACE form) */ + {"test_underscore.example.com", 1}, /* underscore in non-TLD label */ + {"_leading.example.com", 1}, /* underscore at start of label */ + {"trailing_.example.com", 1},/* underscore at end of non-TLD label */ + {"123.numericlabel.example.com", 1}, /* numeric labels are fine */ + {"example.12a3", 1}, /* TLD with letters + digits */ + {"ex--ample.com", 1}, /* double hyphen inside label (allowed) */ + {"A.B.C", 1}, /* uppercase OK (case-insensitive rules) */ + + {"example", 0}, /* single label (not fully qualified) */ + {"example.", 0}, /* becomes single label after dot strip */ + {".example.com", 0}, /* leading dot -- empty first label */ + {"example..com", 0}, /* empty label (consecutive dots) */ + {"-example.com", 0}, /* label starts with '-' */ + {"example-.com", 0}, /* label ends with '-' */ + {"example.com-", 0}, /* final label ends with '-' */ + {"example.com_", 0}, /* underscore in TLD (forbidden) */ + {"example._com", 0}, /* underscore in TLD (forbidden) */ + {"ex@mple.com", 0}, /* illegal character '@' */ + {"example com.com", 0}, /* illegal character ' ' */ + {"", 0}, /* empty string */ + {NULL, 0}, /* NULL pointer */ + {"com", 0}, /* single label */ + {"123.456", 0}, /* all-numeric final label (no alpha) */ + {"example.123", 0}, /* all-numeric TLD (no alpha) */ + {"a", 0}, /* single label, too short */ + {"example.123a", 1}, /* TLD with at least one letter -- valid */ + }; + + int i; + for (i = 0; i < (int)(sizeof(test_cases) / sizeof(test_cases[0])); i++) { + ExpectIntEQ(wolfssl_local_IsValidFQDN( + test_cases[i].str, + test_cases[i].str ? (word32)strlen(test_cases[i].str) : 0), + test_cases[i].is_FQDN); + if (! EXPECT_SUCCESS()) { + fprintf(stderr, "wolfssl_local_IsValidFQDN() wrong result for " + "case %d \"%s\"\n", i, test_cases[i].str); + break; + } + } + + /* Additional corner cases (length & label-size boundaries) */ + { + char buf[300]; + + /* 253 chars (max allowed), with 63 byte labels (max allowed) - valid */ + memset(buf, 'a', 251); + for (i=63; i < 251; i+=64) + buf[i] = '.'; + buf[251] = '.'; + buf[252] = 'b'; + buf[253] = '\0'; + ExpectIntEQ(wolfssl_local_IsValidFQDN(buf, (word32)strlen(buf)), 1); + + /* 254 chars (one too long) - invalid */ + memset(buf, 'a', 252); + for (i=63; i < 251; i+=64) + buf[i] = '.'; + buf[252] = '.'; + buf[253] = 'b'; + buf[254] = '\0'; + ExpectIntEQ(wolfssl_local_IsValidFQDN(buf, (word32)strlen(buf)), 0); + + /* 64-char label (one too long) */ + memset(buf, 'a', 64); + buf[64] = '.'; + buf[65] = 'c'; + buf[66] = 'o'; + buf[67] = 'm'; + buf[68] = '\0'; + ExpectIntEQ(wolfssl_local_IsValidFQDN(buf, (word32)strlen(buf)), 0); + + /* Explicit nameSz == 0 (even with non-NULL pointer) */ + ExpectIntEQ(wolfssl_local_IsValidFQDN("example.com", 0), 0); + } + +#endif /* !NO_ASN && !WOLFCRYPT_ONLY && !NO_CERTS */ + return EXPECT_RESULT(); +} + int test_wolfSSL_X509_max_altnames(void) { EXPECT_DECLS; diff --git a/tests/api/test_ossl_x509.h b/tests/api/test_ossl_x509.h index e2b5167f02e..f0da8a9d8ed 100644 --- a/tests/api/test_ossl_x509.h +++ b/tests/api/test_ossl_x509.h @@ -48,6 +48,7 @@ int test_wolfSSL_X509_bad_altname(void); int test_wolfSSL_X509_name_match1(void); int test_wolfSSL_X509_name_match2(void); int test_wolfSSL_X509_name_match3(void); +int test_wolfssl_local_IsValidFQDN(void); int test_wolfSSL_X509_max_altnames(void); int test_wolfSSL_X509_max_name_constraints(void); int test_wolfSSL_X509_check_ca(void); @@ -79,6 +80,7 @@ int test_wolfSSL_X509_cmp(void); TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_name_match1), \ TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_name_match2), \ TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_name_match3), \ + TEST_DECL_GROUP("ossl_x509", test_wolfssl_local_IsValidFQDN), \ TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_max_altnames), \ TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_max_name_constraints), \ TEST_DECL_GROUP("ossl_x509", test_wolfSSL_X509_check_ca), \ diff --git a/wolfcrypt/src/asn.c b/wolfcrypt/src/asn.c index 088eb47ea5b..030bec8c3bd 100644 --- a/wolfcrypt/src/asn.c +++ b/wolfcrypt/src/asn.c @@ -17820,6 +17820,81 @@ static int ConfirmNameConstraints(Signer* signer, DecodedCert* cert) #endif /* IGNORE_NAME_CONSTRAINTS */ +#if !defined(WOLFCRYPT_ONLY) && !defined(NO_CERTS) +/* Returns 1 if name is a syntactically valid DNS FQDN per RFC 952/1123. + * + * Rules enforced: + * - Total effective length (excluding optional trailing dot) in [1, 253] + * - Each label is 1-63 octets of [a-zA-Z0-9-], with _ allowed in all but + * the last label. + * - No label starts or ends with '-' + * - At least two labels (single-label names are not "fully qualified") + * - Final label (TLD) contains at least one letter (rejects all-numeric + * strings that could be confused with IPv4 literals, and matches the + * ICANN constraint that TLDs are alphabetic) + * - Optional trailing dot is accepted (absolute FQDN form) + * - Internationalized names are valid in their ACE/punycode (xn--) form + */ +int wolfssl_local_IsValidFQDN(const char* name, word32 nameSz) +{ + word32 i; + int labelLen = 0; + int labelCount = 0; + int curLabelHasAlpha = 0; + int curLabelHasUnderscore = 0; + + if (name == NULL || nameSz == 0) + return 0; + + /* Strip a single optional trailing dot before measuring. "example.com." + * is the absolute form of the same FQDN. + */ + if (name[nameSz - 1] == '.') + --nameSz; + + if (nameSz < 1 || nameSz > 253) + return 0; + + for (i = 0; i < nameSz; i++) { + byte c = (byte)name[i]; + + if (c == '.') { + if (labelLen == 0 || name[i - 1] == '-') + return 0; + ++labelCount; + labelLen = 0; + curLabelHasAlpha = 0; + curLabelHasUnderscore = 0; + continue; + } + + if (++labelLen > 63) + return 0; + + if (c == '-') { + if (labelLen == 1) + return 0; + } + else if (((c | 0x20) >= 'a') && ((c | 0x20) <= 'z')) { + curLabelHasAlpha = 1; + } + else if (c == '_') { + curLabelHasUnderscore = 1; + } + else if ((c < '0') || (c > '9')) { + return 0; + } + } + + /* Final label (no trailing dot in the effective range to close it) */ + if ((labelLen == 0) || (name[nameSz - 1] == '-') || curLabelHasUnderscore) + return 0; + ++labelCount; + + return ((labelCount > 1) && curLabelHasAlpha); +} +#endif /* !WOLFCRYPT_ONLY && !NO_CERTS */ + #ifdef WOLFSSL_ASN_TEMPLATE #if defined(WOLFSSL_SEP) || defined(WOLFSSL_FPKI) /* ASN.1 template for OtherName of an X.509 certificate. diff --git a/wolfssl/wolfcrypt/asn.h b/wolfssl/wolfcrypt/asn.h index b3028c9c99d..0695fa25e4e 100644 --- a/wolfssl/wolfcrypt/asn.h +++ b/wolfssl/wolfcrypt/asn.h @@ -3119,6 +3119,11 @@ WOLFSSL_TEST_VIS int wolfssl_local_MatchIpSubnet(const byte* ip, int ipSz, int constraintSz); #endif +#if !defined(WOLFCRYPT_ONLY) && !defined(NO_CERTS) +WOLFSSL_TEST_VIS int wolfssl_local_IsValidFQDN(const char* name, + word32 nameSz); +#endif + #if ((defined(HAVE_ED25519) && defined(HAVE_ED25519_KEY_IMPORT)) \ || (defined(HAVE_CURVE25519) && defined(HAVE_CURVE25519_KEY_IMPORT)) \ || (defined(HAVE_ED448) && defined(HAVE_ED448_KEY_IMPORT)) \