Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/internal.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Comment on lines +13407 to +13411
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MatchDomainName() now calls wolfssl_local_IsValidFQDN(), which is only declared when !NO_ASN (via wolfcrypt/asn.h) and implemented in wolfcrypt/src/asn.c (built only with ASN). If NO_ASN is enabled without also defining NO_CERTS, this introduces a compile/link failure. Consider moving wolfssl_local_IsValidFQDN() into src/internal.c (or otherwise ensuring it’s available whenever MatchDomainName() is built), or wrap the call with #ifndef NO_ASN and provide an alternate path when ASN is disabled.

Copilot uses AI. Check for mistakes.
*/
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);
Expand Down
185 changes: 185 additions & 0 deletions tests/api/test_ossl_x509.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In test_wolfssl_local_IsValidFQDN, the failure diagnostic uses "%s" with test_cases[i].str, but one test case intentionally sets str = NULL. If the NULL-case ever fails (or any future NULL case is added), this error path can crash/UB. Print a placeholder when str == NULL (or use %p) to keep the diagnostic safe.

Suggested change
"case %d \"%s\"\n", i, test_cases[i].str);
"case %d \"%s\"\n", i,
test_cases[i].str ? test_cases[i].str : "(null)");

Copilot uses AI. Check for mistakes.
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. */
Comment on lines +1713 to +1714
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above test_wolfSSL_MatchDomainName_idn says MatchDomainName() is WOLFSSL_LOCAL, but in this PR the declaration was changed to WOLFSSL_TEST_VIS in wolfssl/internal.h. Update the comment to reflect the current visibility/export mechanism (or remove the visibility claim) to avoid misleading future readers.

Suggested change
* MatchDomainName() is WOLFSSL_LOCAL but visible to the test binary because
* tests link against the in-tree library. */
* MatchDomainName() is exposed for testing via the visibility mechanism
* declared in wolfssl/internal.h. */

Copilot uses AI. Check for mistakes.
int test_wolfSSL_MatchDomainName_idn(void)
{
EXPECT_DECLS;
#if !defined(NO_CERTS)
static const struct {
Comment on lines +1717 to +1719
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_wolfSSL_MatchDomainName_idn is currently only gated on !NO_CERTS, but it calls MatchDomainName() with WOLFSSL_LEFT_MOST_WILDCARD_ONLY, which (after this change) depends on wolfssl_local_IsValidFQDN() from ASN code. If there are configurations that build with NO_ASN (or without BUILD_ASN) but still compile this test, it will fail to build/link. Consider guarding this test with !defined(NO_ASN) (or the same feature macro that guarantees wolfssl_local_IsValidFQDN is available).

Copilot uses AI. Check for mistakes.
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;
Expand Down
4 changes: 4 additions & 0 deletions tests/api/test_ossl_x509.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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), \
Expand Down
Loading
Loading