diff --git a/src/internal.c b/src/internal.c index c10b89d6a6..8f6985f2f0 100644 --- a/src/internal.c +++ b/src/internal.c @@ -13320,6 +13320,66 @@ static int MatchIPv6(const char* pattern, int patternLen, } #endif /* WOLFSSL_IP_ALT_NAME && !WOLFSSL_USER_IO */ +/* IDNA A-label prefix (Punycode-encoded internationalized labels), used to + * gate wildcard matching per RFC 6125 sec. 6.4.3 / RFC 9525 sec. 6.3. */ +static int LabelIsALabel(const char* label, word32 labelLen) +{ + if (labelLen < 4) + return 0; + return ((XTOLOWER((unsigned char)label[0]) == 'x') && + (XTOLOWER((unsigned char)label[1]) == 'n') && + (label[2] == '-') && + (label[3] == '-')); +} + +/* Returns 1 if any dot-separated label in name is an A-label. */ +static int NameHasALabel(const char* name, word32 nameLen) +{ + word32 labelStart = 0; + word32 i; + + for (i = 0; i < nameLen; i++) { + if (name[i] == '.') { + if (LabelIsALabel(name + labelStart, i - labelStart)) + return 1; + labelStart = i + 1; + } + } + if (labelStart < nameLen) { + if (LabelIsALabel(name + labelStart, nameLen - labelStart)) + return 1; + } + return 0; +} + +/* Returns 1 if any label of pattern that contains a wildcard ('*') is an + * A-label. RFC 6125 sec. 6.4.3 disallows wildcards embedded in A-labels. */ +static int PatternHasWildcardInALabel(const char* pattern, word32 patternLen) +{ + word32 labelStart = 0; + int labelHasWildcard = 0; + word32 i; + + for (i = 0; i < patternLen; i++) { + if (pattern[i] == '.') { + if (labelHasWildcard && + LabelIsALabel(pattern + labelStart, i - labelStart)) { + return 1; + } + labelStart = i + 1; + labelHasWildcard = 0; + } + else if (pattern[i] == '*') { + labelHasWildcard = 1; + } + } + if (labelHasWildcard && + LabelIsALabel(pattern + labelStart, patternLen - labelStart)) { + return 1; + } + return 0; +} + /* Match names with wildcards, each wildcard can represent a single name component or fragment but not multiple names, i.e., *.z.com matches y.z.com but not x.y.z.com @@ -13344,6 +13404,38 @@ 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; + + /* RFC 6125 sec. 6.4.3 / RFC 9525 sec. 6.3: do not perform wildcard + * matching when the pattern has a wildcard embedded in an A-label, nor + * when the reference identifier (hostname) contains any A-label. The + * existing single-label glob would otherwise match across the + * Punycode-encoded form (e.g., "x*.example.com" matching + * "xn--rger-koa.example.com"), which has no semantic meaning. */ + if (PatternHasWildcardInALabel(pattern, (word32)patternLen)) + return 0; + if (NameHasALabel(str, strLen)) { + int i; + for (i = 0; i < patternLen; i++) { + if (pattern[i] == '*') + return 0; + } + } + 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 ce8546dc24..1d98a396ae 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,183 @@ 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(); +} + +/* Verify that MatchDomainName() refuses to expand wildcards across IDNA + * A-labels (xn-- prefix) per RFC 6125 sec. 6.4.3 / RFC 9525 sec. 6.3. + * + * MatchDomainName() is WOLFSSL_LOCAL but visible to the test binary because + * tests link against the in-tree library. */ +int test_wolfSSL_MatchDomainName_idn(void) +{ + EXPECT_DECLS; +#if !defined(NO_CERTS) + static const struct { + const char* pattern; + const char* host; + unsigned int flags; + int expected; /* 1 = match, 0 = no match */ + const char* note; + } cases[] = { + /* Partial wildcard whose literal prefix overlaps "xn--" must NOT + * match an A-label hostname. */ + { "x*.example.com", "xn--rger-koa.example.com", 0, 0, + "partial wildcard vs A-label" }, + /* Wildcard embedded inside an A-label pattern must NOT match. */ + { "xn--*.example.com", "xn--rger-koa.example.com", 0, 0, + "wildcard inside A-label pattern" }, + /* Full left-most wildcard MUST NOT match an A-label hostname + * (RFC 9525 sec. 6.3 strengthens RFC 6125 SHOULD NOT to MUST NOT). */ + { "*.example.com", "xn--rger-koa.example.com", 0, 0, + "full wildcard vs A-label hostname" }, + /* A-label appearing in an inner label still disables wildcard + * matching against the entire reference identifier. */ + { "*.example.com", "foo.xn--bar.example.com", 0, 0, + "wildcard with A-label in inner label" }, + /* Case-insensitive A-label detection: "XN--" is also an A-label. */ + { "x*.example.com", "XN--rger-koa.example.com", 0, 0, + "uppercase A-label prefix" }, + /* Control: full wildcard SHOULD continue to match plain ASCII. */ + { "*.example.com", "foo.example.com", 0, 1, + "wildcard matches non-IDN" }, + /* Control: exact A-label match (no wildcard in pattern) must work. */ + { "xn--rger-koa.example.com", "xn--rger-koa.example.com", 0, 1, + "exact A-label match" }, + /* Control: a label that merely begins with 'x' (not 'xn--') is not + * an A-label and must still wildcard-match. */ + { "*.example.com", "xyz.example.com", 0, 1, + "non-A-label x-prefix" }, + /* Control: partial wildcard against a non-A-label still works. */ + { "x*.example.com", "xyz.example.com", 0, 1, + "partial wildcard non-IDN" }, + + /* Trailing-dot normalization: absolute-form FQDN ("example.com.") + * must match the same FQDN with or without the trailing dot, on + * either side of the comparison. RFC 1035 / RFC 6125. */ + { "example.com", "example.com.", 0, 1, + "trailing dot on host" }, + { "example.com.", "example.com", 0, 1, + "trailing dot on pattern" }, + { "example.com.", "example.com.", 0, 1, + "trailing dot on both" }, + { "*.example.com", "foo.example.com.", 0, 1, + "trailing dot on host with wildcard pattern" }, + /* Trailing dot must not cause an A-label gate to misfire. */ + { "*.example.com", "xn--rger-koa.example.com.", 0, 0, + "trailing dot on A-label host" }, + /* Same trailing-dot normalization under WOLFSSL_LEFT_MOST_WILDCARD_ONLY. */ + { "*.example.com", "foo.example.com.", + WOLFSSL_LEFT_MOST_WILDCARD_ONLY, 1, + "trailing dot, leftWildcardOnly" }, + }; + size_t i; + + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + int got = MatchDomainName( + cases[i].pattern, (int)XSTRLEN(cases[i].pattern), + cases[i].host, (word32)XSTRLEN(cases[i].host), + cases[i].flags); + ExpectIntEQ(got, cases[i].expected); + if (! EXPECT_SUCCESS()) { + fprintf(stderr, + "MatchDomainName(\"%s\", \"%s\", flags=0x%x) = %d, " + "expected %d (%s)\n", + cases[i].pattern, cases[i].host, cases[i].flags, + got, cases[i].expected, cases[i].note); + break; + } + } +#endif /* !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 e2b5167f02..f2844092af 100644 --- a/tests/api/test_ossl_x509.h +++ b/tests/api/test_ossl_x509.h @@ -48,6 +48,8 @@ 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_MatchDomainName_idn(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 +81,8 @@ 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_MatchDomainName_idn), \ 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 9a3be56616..7c33c060d8 100644 --- a/wolfcrypt/src/asn.c +++ b/wolfcrypt/src/asn.c @@ -17838,6 +17838,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/internal.h b/wolfssl/internal.h index 96606ba4f9..a6d25a2b22 100644 --- a/wolfssl/internal.h +++ b/wolfssl/internal.h @@ -2222,9 +2222,9 @@ WOLFSSL_LOCAL void FreeAsyncCtx(WOLFSSL* ssl, byte freeAsync); WOLFSSL_LOCAL void FreeKeyExchange(WOLFSSL* ssl); WOLFSSL_LOCAL void FreeSuites(WOLFSSL* ssl); WOLFSSL_LOCAL int ProcessPeerCerts(WOLFSSL* ssl, byte* input, word32* inOutIdx, word32 totalSz); -WOLFSSL_LOCAL int MatchDomainName(const char* pattern, int len, - const char* str, word32 strLen, - unsigned int flags); +WOLFSSL_TEST_VIS int MatchDomainName(const char* pattern, int len, + const char* str, word32 strLen, + unsigned int flags); #if !defined(NO_CERTS) && !defined(NO_ASN) WOLFSSL_LOCAL int CheckForAltNames(DecodedCert* dCert, const char* domain, word32 domainLen, int* checkCN, diff --git a/wolfssl/wolfcrypt/asn.h b/wolfssl/wolfcrypt/asn.h index b3028c9c99..0695fa25e4 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)) \