Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
deba964
[ANCHOR-1400]: Stop a wildcard home domain from matching URL-structur…
ceciliaromao Oct 28, 2026
02fb998
[ANCHOR-1400]: Cover the SEP-10 wildcard home domain rejection
ceciliaromao Oct 28, 2026
51c8f3e
[ANCHOR-1400]: Match and sign the SEP-45 home domain with the shared …
ceciliaromao Oct 28, 2026
a3e4442
[ANCHOR-1400]: Validate the SEP-45 home domain before building the ch…
ceciliaromao Oct 28, 2026
ef27432
[ANCHOR-1400]: Expect the normalized home domain in the SEP-45 integr…
ceciliaromao Oct 28, 2026
5750520
[ANCHOR-1400]: Reject an empty SEP-45 home domain and close the gaps …
ceciliaromao Oct 28, 2026
a94e75a
[ANCHOR-1400]: Reject a wildcard home domain match whose host has an …
ceciliaromao Oct 30, 2026
8549a2b
[ANCHOR-1400]: Cover the wildcard home domain rejection at the SEP-45…
ceciliaromao Oct 30, 2026
38ae0bc
[ANCHOR-1401]: Pin the reporter's wildcard home domain strings at the…
ceciliaromao Oct 30, 2026
48c62eb
[ANCHOR-1401]: Pin the reporter's wildcard home domain string at the …
ceciliaromao Oct 30, 2026
facd5fc
[ANCHOR-1401]: Reject a wildcard home domain match over the DNS label…
ceciliaromao Oct 30, 2026
a297107
[ANCHOR-1401]: Cover the wildcard home domain length limits at the SE…
ceciliaromao Oct 30, 2026
768b070
[ANCHOR-1401]: Cover the wildcard home domain length limits at the SE…
ceciliaromao Oct 30, 2026
31d86b1
[ANCHOR-1401]: Reject a wildcard home domain over the DNS length limi…
amandagonsalves Nov 12, 2026
5a63e5b
Merge branch 'develop' into fix/anchor-1400
ceciliaromao Nov 13, 2026
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
50 changes: 39 additions & 11 deletions core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static org.stellar.sdk.Auth.authorizeEntry;

import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.*;
import lombok.AllArgsConstructor;
Expand Down Expand Up @@ -59,6 +58,9 @@ public ChallengeResponse getChallenge(ChallengeRequest request) throws AnchorExc
if (isEmpty(request.getHomeDomain())) {
throw new BadRequestException("home_domain is required");
}
if (!isHomeDomainAllowed(request.getHomeDomain())) {
throw new BadRequestException("Invalid home domain");
}

KeyPair signingKeypair = KeyPair.fromSecretSeed(secretConfig.getSep10SigningSeed());
// Transaction simulation does not require a real account, but it does need to be different from
Expand Down Expand Up @@ -249,7 +251,7 @@ public ValidationResponse validate(ValidationRequest request) throws AnchorExcep
// Construct the JWT token
long issuedAt = Instant.now().getEpochSecond();
String account = argsMap.get(KEY_ACCOUNT);
String homeDomain = argsMap.get(KEY_HOME_DOMAIN);
String homeDomain = normalizeHomeDomain(argsMap.get(KEY_HOME_DOMAIN));
String clientDomain = argsMap.get(KEY_CLIENT_DOMAIN);

String hashHex;
Expand Down Expand Up @@ -382,15 +384,7 @@ private Map<String, String> extractArgs(SCMapEntry[] entries) {
private void verifyArguments(SCMapEntry[] entries) throws BadRequestException, SepException {
Map<String, String> argsMap = extractArgs(entries);

if (sep45Config.getHomeDomains().stream()
.noneMatch(
homeDomain -> {
URI expected = URI.create("http://" + homeDomain.replaceFirst("^https?://", ""));
URI given =
URI.create(
"http://" + argsMap.get(KEY_HOME_DOMAIN).replaceFirst("^https?://", ""));
return expected.getAuthority().equalsIgnoreCase(given.getAuthority());
})) {
if (!isHomeDomainAllowed(argsMap.get(KEY_HOME_DOMAIN))) {
throw new BadRequestException("Invalid home domain");
}

Expand Down Expand Up @@ -418,6 +412,40 @@ private void verifyArguments(SCMapEntry[] entries) throws BadRequestException, S
}
}

/**
* Lower-cases a home domain and drops one leading http(s):// so equivalent spellings compare
* equal.
*
* @param homeDomain the home domain as configured or as supplied by a client
* @return the normalized home domain, or null if none was given
*/
private static String normalizeHomeDomain(String homeDomain) {
return homeDomain == null
? null
: homeDomain.toLowerCase(Locale.ROOT).replaceFirst("^https?://", "");
}

/**
* Checks a home domain against the configured home domains with the matcher SEP-10 uses, on
* normalized values.
*
* @param homeDomain the home domain supplied by a client, or null if it is missing
* @return true if the home domain is not empty and is exactly a configured domain or matches a
* configured wildcard
*/
private boolean isHomeDomainAllowed(String homeDomain) {
if (homeDomain == null) {
return false;
}
String normalized = normalizeHomeDomain(homeDomain);
if (normalized.isEmpty()) {
return false;
}
List<String> configured =
sep45Config.getHomeDomains().stream().map(Sep45Service::normalizeHomeDomain).toList();
return ClientDomainHelper.isDomainNameMatch(configured, normalized);
}

private void validateClientDomainAllowed(String clientDomain) throws SepNotAuthorizedException {
List<String> clientAllowList = sep45Config.getClientAllowList();
if (clientAllowList != null && !clientAllowList.isEmpty()) {
Expand Down
38 changes: 35 additions & 3 deletions core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public class ClientDomainHelper {
new ClientDomainThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());

private static final int MAX_LABEL_LENGTH = 63;
private static final int MAX_HOST_LENGTH = 253;

private static final long BOUNDED_FETCH_TIMEOUT_MS = 2_500;
private static final long FETCH_CALL_TIMEOUT_MS = 1_000;
private static final OkHttpClient FETCH_CLIENT = OkHttpUtil.buildClient(FETCH_CALL_TIMEOUT_MS);
Expand Down Expand Up @@ -155,11 +158,12 @@ public static Boolean isDomainNameMatch(List<String> patternsAndDomains, String
for (String patternOrDomain : patternsAndDomains) {
if (patternOrDomain.contains("*")) {
// wildcard domain
// Escape special characters in the pattern and replace '*' with '.*'
String regex = patternOrDomain.replace(".", "\\.").replace("*", ".*");
// Escape special characters in the pattern and let '*' span only host-name characters, so
// it can never match '/', '?', '#', '@', '\', ':' or whitespace.
String regex = patternOrDomain.replace(".", "\\.").replace("*", "[A-Za-z0-9_.-]*");
Pattern patternObject = Pattern.compile(regex);
Matcher matcher = patternObject.matcher(domainName);
if (matcher.matches()) {
if (matcher.matches() && hasValidHostLabels(domainName)) {
return true;
}
} else {
Expand All @@ -172,6 +176,34 @@ public static Boolean isDomainNameMatch(List<String> patternsAndDomains, String
return false;
}

/**
* Checks that the host part of a domain name (without a trailing port) is at most 253 characters
* long and made of non-empty labels of at most 63 characters that neither start nor end with a
* hyphen, so a wildcard cannot expand to something that is not a host name.
*
* @param domainName The domain name, optionally followed by ":port".
* @return true if the host part is well formed, false otherwise.
*/
private static boolean hasValidHostLabels(String domainName) {
String host = domainName;
int portSeparator = host.lastIndexOf(':');
if (portSeparator >= 0) {
host = host.substring(0, portSeparator);
}
if (host.length() > MAX_HOST_LENGTH) {
return false;
}
for (String label : host.split("\\.", -1)) {
if (label.isEmpty()
|| label.length() > MAX_LABEL_LENGTH
|| label.startsWith("-")
|| label.endsWith("-")) {
return false;
}
}
return true;
}

/**
* Retrieves the first fixed domain name from the provided list of patterns and domains.
*
Expand Down
129 changes: 129 additions & 0 deletions core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,135 @@ internal class Sep10ServiceTest {
assertThrows<SepValidationException> { sep10Service.createChallenge(cr) }
}

@Test
fun `Test request to create challenge with a wildcard home domain spanning a path failure`() {
every { sep10Config.homeDomains } returns listOf("*.stellar.org")
val cr =
ChallengeRequest.builder()
.account(TEST_ACCOUNT)
.memo(TEST_MEMO)
.homeDomain("evil.example/x.stellar.org")
.clientDomain(TEST_CLIENT_DOMAIN)
.build()

val ex = assertThrows<SepValidationException> { sep10Service.createChallenge(cr) }

assertEquals("home_domain [evil.example/x.stellar.org] is not supported.", ex.message)
}

@Test
fun `Test createChallenge rejects a wildcard home domain spanning a path under the wildcard fixture`() {
every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN)
every { sep10Config.knownCustodialAccountList } returns listOf(TEST_ACCOUNT)

listOf("evil.com/x.wildcard.stellar.org", "a.wildcard.stellar.org/evil").forEach { homeDomain ->
val cr =
ChallengeRequest.builder()
.account(TEST_ACCOUNT)
.memo(TEST_MEMO)
.homeDomain(homeDomain)
.clientDomain(null)
.build()

val ex = assertThrows<SepValidationException> { sep10Service.createChallenge(cr) }

assertEquals("home_domain [$homeDomain] is not supported.", ex.message)
}
}

@Test
fun `Test createChallenge accepts multi-label wildcard home domains under the wildcard fixture`() {
every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN)
every { sep10Config.knownCustodialAccountList } returns listOf(TEST_ACCOUNT)

listOf("abc.def.wildcard.stellar.org", "a.wildcard.stellar.org").forEach { homeDomain ->
val cr =
ChallengeRequest.builder()
.account(TEST_ACCOUNT)
.memo(TEST_MEMO)
.homeDomain(homeDomain)
.clientDomain(null)
.build()

val response = sep10Service.createChallenge(cr)

val txn = Transaction.fromEnvelopeXdr(response.transaction, TESTNET) as Transaction
val keyName = (txn.operations[0] as ManageDataOperation).name
assertTrue(
keyName.startsWith(homeDomain),
"key name [$keyName] should start with $homeDomain"
)
}
}

@Test
fun `Test createChallenge rejects an over-long wildcard home domain`() {
every { sep10Config.homeDomains } returns listOf("*.sdp.stellar.org")
every { sep10Config.knownCustodialAccountList } returns listOf(TEST_ACCOUNT)
val longLabel = "a".repeat(64) + ".sdp.stellar.org"
val longHost = listOf(63, 63, 63, 46).joinToString(".") { "a".repeat(it) } + ".sdp.stellar.org"
assertEquals(254, longHost.length)

listOf(longLabel, longHost).forEach { homeDomain ->
val cr =
ChallengeRequest.builder()
.account(TEST_ACCOUNT)
.memo(TEST_MEMO)
.homeDomain(homeDomain)
.clientDomain(null)
.build()

val ex = assertThrows<SepValidationException> { sep10Service.createChallenge(cr) }

assertEquals("home_domain [$homeDomain] is not supported.", ex.message)
}
}

@Test
fun `Test validateChallenge rejects a wildcard home domain spanning a path`() {
every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN)
val vr = ValidationRequest()
vr.transaction = createTestChallenge("", "evil.com/x.wildcard.stellar.org", false)

val ex = assertThrows<SepValidationException> { sep10Service.validateChallenge(vr) }

assertEquals(
"The transaction's operation key name does not include one of the expected home domains.",
ex.message
)
verify(exactly = 0) { jwtService.encode(any<Sep10Jwt>()) }
}

@Test
fun `Test validateChallengeTransactionHomeDomain rejects a wildcard home domain spanning a path`() {
every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN)
val challenge =
mockk<Sep10Challenge.ChallengeTransaction> {
every { matchedHomeDomain } returns "evil.com/x.wildcard.stellar.org"
}

val ex =
assertThrows<SepValidationException> {
sep10Service.validateChallengeTransactionHomeDomain(challenge)
}

assertEquals("Invalid home_domain. evil.com/x.wildcard.stellar.org", ex.message)
}

@Test
fun `Test validateChallengeTransactionHomeDomain accepts a multi-label wildcard home domain`() {
every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN)
val challenge =
mockk<Sep10Challenge.ChallengeTransaction> {
every { matchedHomeDomain } returns "abc.def.wildcard.stellar.org"
}

assertEquals(
"abc.def.wildcard.stellar.org",
sep10Service.validateChallengeTransactionHomeDomain(challenge)
)
}

@Test
@LockAndMockStatic([NetUtil::class, ClientDomainHelper::class])
fun `Test create challenge with wildcard matched home domain success`() {
Expand Down
Loading
Loading