From deba9648210ea49cdb7003a49ac7c10956018ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 10:32:17 -0300 Subject: [PATCH 01/13] [ANCHOR-1400]: Stop a wildcard home domain from matching URL-structure characters --- .../anchor/util/ClientDomainHelper.java | 5 +- .../anchor/util/ClientDomainHelperTest.kt | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index c2f47b9b42..f9bf722d14 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -155,8 +155,9 @@ public static Boolean isDomainNameMatch(List 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()) { diff --git a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt index b5bf184303..1cf372fd15 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt @@ -244,4 +244,50 @@ class ClientDomainHelperTest { fun `test extractHostname with bracketed IPv6 and port`() { assertEquals("::1", ClientDomainHelper.extractHostname("[::1]:8080")) } + + @Test + fun `isDomainNameMatch accepts a multi-level wildcard subdomain`() { + val patterns = listOf("*.sdp.stellar.org") + assertTrue(ClientDomainHelper.isDomainNameMatch(patterns, "foo.sdp.stellar.org")) + assertTrue(ClientDomainHelper.isDomainNameMatch(patterns, "a.b.sdp.stellar.org")) + } + + @Test + fun `isDomainNameMatch rejects structural characters spanned by a wildcard`() { + val patterns = listOf("*.sdp.stellar.org") + listOf( + "evil.example/x.sdp.stellar.org", + "evil.example?.sdp.stellar.org", + "evil.example#.sdp.stellar.org", + "u@evil.example.sdp.stellar.org", + "evil.example\\x.sdp.stellar.org", + "evil.example:80.sdp.stellar.org", + "a b.sdp.stellar.org", + ) + .forEach { + assertFalse(ClientDomainHelper.isDomainNameMatch(patterns, it), "should reject: $it") + } + } + + @Test + fun `isDomainNameMatch keeps matching plain exact and wildcard hostnames`() { + assertTrue(ClientDomainHelper.isDomainNameMatch(listOf("localhost:8080"), "localhost:8080")) + assertFalse(ClientDomainHelper.isDomainNameMatch(listOf("localhost:8080"), "localhost:8081")) + assertFalse(ClientDomainHelper.isDomainNameMatch(listOf("stellar.org"), "foo.stellar.org")) + assertTrue( + ClientDomainHelper.isDomainNameMatch(listOf("*.stellar.org"), "foo_bar-1.stellar.org") + ) + assertTrue( + ClientDomainHelper.isDomainNameMatch(listOf("*.example.com:8080"), "a.example.com:8080") + ) + assertFalse( + ClientDomainHelper.isDomainNameMatch(listOf("*.example.com:8080"), "a.example.com:9090") + ) + assertTrue( + ClientDomainHelper.isDomainNameMatch( + listOf("localhost:8080", "*.stellar.org"), + "x.stellar.org" + ) + ) + } } From 02fb998996cf01542f0568b0ddba1b9e15beb21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 10:34:00 -0300 Subject: [PATCH 02/13] [ANCHOR-1400]: Cover the SEP-10 wildcard home domain rejection --- .../org/stellar/anchor/sep10/Sep10ServiceTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt index 2a7928b231..48776baeb9 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -1061,6 +1061,22 @@ internal class Sep10ServiceTest { assertThrows { 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 { sep10Service.createChallenge(cr) } + + assertEquals("home_domain [evil.example/x.stellar.org] is not supported.", ex.message) + } + @Test @LockAndMockStatic([NetUtil::class, ClientDomainHelper::class]) fun `Test create challenge with wildcard matched home domain success`() { From 51c8f3e1d0ec60ec015988a08f6a40aab2eede39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 10:36:36 -0300 Subject: [PATCH 03/13] [ANCHOR-1400]: Match and sign the SEP-45 home domain with the shared matcher on validate --- .../stellar/anchor/sep45/Sep45Service.java | 42 +++-- .../stellar/anchor/sep45/Sep45ServiceTest.kt | 155 ++++++++++++++++++ 2 files changed, 186 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java index 4b0f919f19..06bbbce659 100644 --- a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java +++ b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java @@ -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; @@ -249,7 +248,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; @@ -382,15 +381,7 @@ private Map extractArgs(SCMapEntry[] entries) { private void verifyArguments(SCMapEntry[] entries) throws BadRequestException, SepException { Map 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"); } @@ -418,6 +409,35 @@ 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 exactly a configured domain or matches a configured wildcard + */ + private boolean isHomeDomainAllowed(String homeDomain) { + if (homeDomain == null) { + return false; + } + List configured = + sep45Config.getHomeDomains().stream().map(Sep45Service::normalizeHomeDomain).toList(); + return ClientDomainHelper.isDomainNameMatch(configured, normalizeHomeDomain(homeDomain)); + } + private void validateClientDomainAllowed(String clientDomain) throws SepNotAuthorizedException { List clientAllowList = sep45Config.getClientAllowList(); if (clientAllowList != null && !clientAllowList.isEmpty()) { diff --git a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt index 21153ffaec..89bad9a29f 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -24,12 +24,17 @@ import org.stellar.anchor.ledger.StellarRpc import org.stellar.anchor.util.ClientDomainHelper import org.stellar.anchor.util.GsonUtils import org.stellar.sdk.Asset +import org.stellar.sdk.Auth import org.stellar.sdk.KeyPair import org.stellar.sdk.Network import org.stellar.sdk.SorobanServer import org.stellar.sdk.responses.sorobanrpc.GetLatestLedgerResponse import org.stellar.sdk.responses.sorobanrpc.GetNetworkResponse import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse +import org.stellar.sdk.scval.Scv +import org.stellar.sdk.xdr.SCAddressType +import org.stellar.sdk.xdr.SCMap +import org.stellar.sdk.xdr.SCMapEntry import org.stellar.sdk.xdr.SorobanAuthorizationEntries @ExtendWith(LockAndMockTest::class) @@ -320,6 +325,156 @@ class Sep45ServiceTest { assertEquals("authorization_entries must contain at least one entry", ex.message) } + private val rejectedHomeDomains = + listOf( + "localhost:8080/evil", + "localhost:8080?x=1", + "localhost:8080#f", + "localhost:8080\\evil", + "localhost:8080@evil.example", + "localhost:8081", + "http://http://localhost:8080", + ) + + /** + * Re-writes the `home_domain` argument of the valid fixture entries (all entries carry the same + * arguments) and re-signs the server entry, so the service sees a well-formed, server-signed + * response for any `home_domain`. A null [homeDomain] drops the argument. + */ + private fun authEntriesWithHomeDomain(homeDomain: String?): ValidationRequest { + val signingKp = + KeyPair.fromSecretSeed("SAX3AH622R2XT6DXWWSRIDCMMUCCMATBZ5U6XKJWDO7M2EJUBFC3AW5X") + val network = Network(Network.TESTNET.networkPassphrase) + val entries = + SorobanAuthorizationEntries.fromXdrBase64(VALID_AUTH_ENTRIES_XDR).sorobanAuthorizationEntries + val rewritten = + entries.map { entry -> + val args = entry.rootInvocation.function.contractFn.args + val kept = + args[0] + .map + .scMap + .filter { it.key.sym.scSymbol.toString() != "home_domain" } + .toMutableList() + if (homeDomain != null) { + kept.add( + SCMapEntry.builder() + .key(Scv.toSymbol("home_domain")) + .`val`(Scv.toString(homeDomain)) + .build() + ) + } + args[0].map = SCMap(kept.sortedBy { it.key.sym.scSymbol.toString() }.toTypedArray()) + val isServerEntry = + entry.credentials.address.address.discriminant == SCAddressType.SC_ADDRESS_TYPE_ACCOUNT && + KeyPair.fromXdrPublicKey(entry.credentials.address.address.accountId.accountID) + .accountId == signingKp.accountId + if (isServerEntry) Auth.authorizeEntry(entry, signingKp, 123465L, network) else entry + } + return ValidationRequest.builder() + .authorizationEntries(SorobanAuthorizationEntries(rewritten.toTypedArray()).toXdrBase64()) + .build() + } + + private fun captureEncodedJwt(): CapturingSlot { + val jwt = slot() + every { jwtService.encode(capture(jwt)) } returns "header.payload.signature" + return jwt + } + + @Test + fun `test validate rejects a home_domain that is not the configured domain`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + every { jwtService.encode(any()) } returns "header.payload.signature" + + rejectedHomeDomains.forEach { + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(it)) + } + assertEquals("Invalid home domain", ex.message, "message for: $it") + } + verify(exactly = 0) { jwtService.encode(any()) } + } + + @Test + fun `test validate rejects entries without home_domain`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(null)) + } + assertEquals("Invalid home domain", ex.message) + } + + @Test + fun `test validate signs a wildcard subdomain`() { + every { sep45Config.homeDomains } returns listOf("*.sdp.stellar.org") + val jwt = captureEncodedJwt() + + sep45Service.validate(authEntriesWithHomeDomain("foo.sdp.stellar.org")) + + assertEquals("foo.sdp.stellar.org", jwt.captured.iss) + assertEquals("foo.sdp.stellar.org", jwt.captured.homeDomain) + } + + @Test + fun `test validate accepts a scheme prefix and mixed case`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + captureEncodedJwt() + + listOf( + "http://localhost:8080", + "https://localhost:8080", + "LOCALHOST:8080", + "HTTP://Localhost:8080" + ) + .forEach { + val response = sep45Service.validate(authEntriesWithHomeDomain(it)) + assertEquals("header.payload.signature", response.token, "token for: $it") + } + } + + @Test + fun `test validate signs the normalized home_domain as iss and home_domain`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + val jwt = captureEncodedJwt() + + listOf("https://localhost:8080", "HTTP://Localhost:8080", "LOCALHOST:8080").forEach { + sep45Service.validate(authEntriesWithHomeDomain(it)) + + assertEquals("localhost:8080", jwt.captured.iss, "iss for: $it") + assertEquals("localhost:8080", jwt.captured.homeDomain, "home_domain for: $it") + } + } + + @Test + fun `test validate accepts a value matching either of several configured domains`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080", "*.sdp.stellar.org") + val jwt = captureEncodedJwt() + + listOf("localhost:8080", "foo.sdp.stellar.org").forEach { + sep45Service.validate(authEntriesWithHomeDomain(it)) + + assertEquals(it, jwt.captured.homeDomain, "home_domain for: $it") + } + } + + @Test + fun `test validate with the exact configured domain signs it`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + val jwt = captureEncodedJwt() + + sep45Service.validate(authEntriesWithHomeDomain("localhost:8080")) + + assertEquals("localhost:8080", jwt.captured.iss) + assertEquals("localhost:8080", jwt.captured.homeDomain) + } + + private val VALID_AUTH_ENTRIES_XDR = + "AAAAAgAAAAEAAAABMXx6BpvcLPv+Ys0+pKSVYuT5yS/rba13ZMWowRmb+pxF2uWzaxsY/AAIcHUAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAg0rDjCmCu2tWgC4nvNxeBkA6AXR61vOlF9kmFcoEQPlUAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAo074x7qA8Iqyn/P1Ewffdh7zMeBtIHvcMhTaUyIBzPEyTx67xLr9pO2AToTSh/VHFki+g3lfEz8eZsh0w0b0BQAAAAAAAAAB9rB6Ki9HordR0vv2WusMZEtYjSf5gJC5lIzUxSZAimgAAAAPd2ViX2F1dGhfdmVyaWZ5AAAAAAEAAAARAAAAAQAAAAUAAAAPAAAAB2FjY291bnQAAAAADgAAADhDQVlYWTZRR1RQT0NaNjc2TUxHVDVKRkVTVlJPSjZPSkY3VlczTExYTVRDMlJRSVpUUDVKWU5FTAAAAA8AAAALaG9tZV9kb21haW4AAAAADgAAABVodHRwOi8vbG9jYWxob3N0OjgwODAAAAAAAAAPAAAABW5vbmNlAAAAAAAADgAAACRkOTQ2YTFiOS01MzExLTQxMDgtYmQ1MC1hM2YxZjQ4YWY4ZDYAAAAPAAAAD3dlYl9hdXRoX2RvbWFpbgAAAAAOAAAADmxvY2FsaG9zdDo4MDgwAAAAAAAPAAAAF3dlYl9hdXRoX2RvbWFpbl9hY2NvdW50AAAAAA4AAAA4R0NITEhEQk9LRzJKV01KUUJUTFNMNVhHNk5PN0VTWEkyVEFRS1pYQ1hXWEI1V0kyWDZXMjMzUFIAAAAAAAAAAQAAAAAAAAAAjrOMLlG0mzEwDNcl9ubzXfJK6NTBBWbiva4e2Rq/ra0rVNLbc72XYAAIcHUAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgjrOMLlG0mzEwDNcl9ubzXfJK6NTBBWbiva4e2Rq/ra0AAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAw4WS+M2bdw9HoLBOiFT9DjqU02Z8gm13Mk0/sBS2AIdC7AbxmoWtS/o1A6feb/hNixTaSBArU0SZKx/l3p5TBAAAAAAAAAAB9rB6Ki9HordR0vv2WusMZEtYjSf5gJC5lIzUxSZAimgAAAAPd2ViX2F1dGhfdmVyaWZ5AAAAAAEAAAARAAAAAQAAAAUAAAAPAAAAB2FjY291bnQAAAAADgAAADhDQVlYWTZRR1RQT0NaNjc2TUxHVDVKRkVTVlJPSjZPSkY3VlczTExYTVRDMlJRSVpUUDVKWU5FTAAAAA8AAAALaG9tZV9kb21haW4AAAAADgAAABVodHRwOi8vbG9jYWxob3N0OjgwODAAAAAAAAAPAAAABW5vbmNlAAAAAAAADgAAACRkOTQ2YTFiOS01MzExLTQxMDgtYmQ1MC1hM2YxZjQ4YWY4ZDYAAAAPAAAAD3dlYl9hdXRoX2RvbWFpbgAAAAAOAAAADmxvY2FsaG9zdDo4MDgwAAAAAAAPAAAAF3dlYl9hdXRoX2RvbWFpbl9hY2NvdW50AAAAAA4AAAA4R0NITEhEQk9LRzJKV01KUUJUTFNMNVhHNk5PN0VTWEkyVEFRS1pYQ1hXWEI1V0kyWDZXMjMzUFIAAAAA" + val jsonSimulateTransactionResponse = """ { From a3e44423ea277b6f03bdea88b68be698b7223e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 10:38:03 -0300 Subject: [PATCH 04/13] [ANCHOR-1400]: Validate the SEP-45 home domain before building the challenge --- .../stellar/anchor/sep45/Sep45Service.java | 3 + .../stellar/anchor/sep45/Sep45ServiceTest.kt | 77 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java index 06bbbce659..ca0f33838c 100644 --- a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java +++ b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java @@ -58,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 diff --git a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt index 89bad9a29f..334f45c65f 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -28,6 +28,7 @@ import org.stellar.sdk.Auth import org.stellar.sdk.KeyPair import org.stellar.sdk.Network import org.stellar.sdk.SorobanServer +import org.stellar.sdk.operations.InvokeHostFunctionOperation import org.stellar.sdk.responses.sorobanrpc.GetLatestLedgerResponse import org.stellar.sdk.responses.sorobanrpc.GetNetworkResponse import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse @@ -325,6 +326,82 @@ class Sep45ServiceTest { assertEquals("authorization_entries must contain at least one entry", ex.message) } + private fun challengeRequestFor(homeDomain: String) = + ChallengeRequest.builder() + .account(TEST_CONTRACT_ID) + .homeDomain(homeDomain) + .clientDomain(null) + .build() + + @Test + fun `test getChallenge rejects a home_domain that is not the configured domain`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + + rejectedHomeDomains.forEach { + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.getChallenge(challengeRequestFor(it)) + } + assertEquals("Invalid home domain", ex.message, "message for: $it") + } + } + + @Test + fun `test getChallenge rejecting a home_domain creates no nonce and runs no simulation`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + + rejectedHomeDomains.forEach { + assertThrows(BadRequestException::class.java) { + sep45Service.getChallenge(challengeRequestFor(it)) + } + } + + verify(exactly = 0) { nonceManager.create(any()) } + verify(exactly = 0) { stellarRpc.simulateTransaction(any()) } + } + + @Test + fun `test getChallenge accepts a wildcard subdomain`() { + every { sep45Config.homeDomains } returns listOf("*.sdp.stellar.org") + + val response = sep45Service.getChallenge(challengeRequestFor("foo.sdp.stellar.org")) + + assertNotNull(response.authorizationEntries) + } + + @Test + fun `test getChallenge accepts a scheme prefix and mixed case`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + + listOf("http://localhost:8080", "https://localhost:8080", "LOCALHOST:8080").forEach { + val response = sep45Service.getChallenge(challengeRequestFor(it)) + assertNotNull(response.authorizationEntries, "challenge for: $it") + } + } + + @Test + fun `test getChallenge builds the challenge with the supplied home_domain unchanged`() { + every { sep45Config.homeDomains } returns listOf("localhost:8080") + val simulated = slot() + every { stellarRpc.simulateTransaction(capture(simulated)) } returns + GsonUtils.getInstance() + .fromJson(jsonSimulateTransactionResponse, SimulateTransactionResponse::class.java) + + sep45Service.getChallenge(challengeRequestFor("LOCALHOST:8080")) + + val operation = simulated.captured.operations[0] as InvokeHostFunctionOperation + val homeDomain = + operation.hostFunction.invokeContract.args[0] + .map + .scMap + .first { it.key.sym.scSymbol.toString() == "home_domain" } + .`val` + .str + .scString + .toString() + assertEquals("LOCALHOST:8080", homeDomain) + } + private val rejectedHomeDomains = listOf( "localhost:8080/evil", From ef274327df49f97b9a8d0c0593e714b2bfeb393b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 10:50:08 -0300 Subject: [PATCH 05/13] [ANCHOR-1400]: Expect the normalized home domain in the SEP-45 integration assertions --- .../anchor/platform/integrationtest/Sep45Tests.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep45Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep45Tests.kt index aa360acb76..c0b552ac0f 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep45Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep45Tests.kt @@ -47,6 +47,8 @@ class Sep45Tests : IntegrationTestBase(TestConfig()) { private var webAuthDomain = toml.getString("WEB_AUTH_FOR_CONTRACTS_ENDPOINT") private var homeDomain = "http://${URI.create(webAuthDomain).authority}" + // The platform tolerates the scheme in the request but signs the bare domain into the token. + private var expectedHomeDomain = URI.create(webAuthDomain).authority @BeforeEach fun checkStellarRpc() { @@ -75,10 +77,10 @@ class Sep45Tests : IntegrationTestBase(TestConfig()) { val jwt = jwtService.decode(validationResponse.token, Sep45Jwt::class.java) - assertEquals(homeDomain, jwt.homeDomain) + assertEquals(expectedHomeDomain, jwt.homeDomain) assertEquals(CLIENT_SMART_WALLET_ACCOUNT, jwt.account) assertNotNull(jwt.jti) - assertEquals(homeDomain, jwt.iss) + assertEquals(expectedHomeDomain, jwt.iss) assertNotNull(jwt.issuedAt) assertNotNull(jwt.expiresAt) } @@ -182,10 +184,10 @@ class Sep45Tests : IntegrationTestBase(TestConfig()) { val jwt = jwtService.decode(validationResponse.token, Sep45Jwt::class.java) - assertEquals(homeDomain, jwt.homeDomain) + assertEquals(expectedHomeDomain, jwt.homeDomain) assertEquals(CLIENT_SMART_WALLET_ACCOUNT, jwt.account) assertNotNull(jwt.jti) - assertEquals(homeDomain, jwt.iss) + assertEquals(expectedHomeDomain, jwt.iss) assertNotNull(jwt.issuedAt) assertNotNull(jwt.expiresAt) } From 5750520e43f192e852ce78ad1677d935376543da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 28 Oct 2026 11:04:18 -0300 Subject: [PATCH 06/13] [ANCHOR-1400]: Reject an empty SEP-45 home domain and close the gaps the verification found in the tests --- .../stellar/anchor/sep45/Sep45Service.java | 9 ++- .../stellar/anchor/sep45/Sep45ServiceTest.kt | 72 +++++++++++++------ .../anchor/util/ClientDomainHelperTest.kt | 1 + 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java index ca0f33838c..31b0f69698 100644 --- a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java +++ b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java @@ -430,15 +430,20 @@ private static String normalizeHomeDomain(String homeDomain) { * normalized values. * * @param homeDomain the home domain supplied by a client, or null if it is missing - * @return true if the home domain is exactly a configured domain or matches a configured wildcard + * @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 configured = sep45Config.getHomeDomains().stream().map(Sep45Service::normalizeHomeDomain).toList(); - return ClientDomainHelper.isDomainNameMatch(configured, normalizeHomeDomain(homeDomain)); + return ClientDomainHelper.isDomainNameMatch(configured, normalized); } private void validateClientDomainAllowed(String clientDomain) throws SepNotAuthorizedException { diff --git a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt index 334f45c65f..595cc3e342 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -373,10 +373,16 @@ class Sep45ServiceTest { fun `test getChallenge accepts a scheme prefix and mixed case`() { every { sep45Config.homeDomains } returns listOf("localhost:8080") - listOf("http://localhost:8080", "https://localhost:8080", "LOCALHOST:8080").forEach { - val response = sep45Service.getChallenge(challengeRequestFor(it)) - assertNotNull(response.authorizationEntries, "challenge for: $it") - } + listOf( + "http://localhost:8080", + "https://localhost:8080", + "LOCALHOST:8080", + "HTTP://Localhost:8080", + ) + .forEach { + val response = sep45Service.getChallenge(challengeRequestFor(it)) + assertNotNull(response.authorizationEntries, "challenge for: $it") + } } @Test @@ -387,19 +393,21 @@ class Sep45ServiceTest { GsonUtils.getInstance() .fromJson(jsonSimulateTransactionResponse, SimulateTransactionResponse::class.java) - sep45Service.getChallenge(challengeRequestFor("LOCALHOST:8080")) - - val operation = simulated.captured.operations[0] as InvokeHostFunctionOperation - val homeDomain = - operation.hostFunction.invokeContract.args[0] - .map - .scMap - .first { it.key.sym.scSymbol.toString() == "home_domain" } - .`val` - .str - .scString - .toString() - assertEquals("LOCALHOST:8080", homeDomain) + listOf("LOCALHOST:8080", "http://localhost:8080").forEach { + sep45Service.getChallenge(challengeRequestFor(it)) + + val operation = simulated.captured.operations[0] as InvokeHostFunctionOperation + val homeDomain = + operation.hostFunction.invokeContract.args[0] + .map + .scMap + .first { entry -> entry.key.sym.scSymbol.toString() == "home_domain" } + .`val` + .str + .scString + .toString() + assertEquals(it, homeDomain, "home_domain argument for: $it") + } } private val rejectedHomeDomains = @@ -411,6 +419,7 @@ class Sep45ServiceTest { "localhost:8080@evil.example", "localhost:8081", "http://http://localhost:8080", + "ftp://localhost:8080", ) /** @@ -476,13 +485,30 @@ class Sep45ServiceTest { @Test fun `test validate rejects entries without home_domain`() { - every { sep45Config.homeDomains } returns listOf("localhost:8080") + listOf(listOf("localhost:8080"), listOf("*.sdp.stellar.org")).forEach { configured -> + every { sep45Config.homeDomains } returns configured - val ex = - assertThrows(BadRequestException::class.java) { - sep45Service.validate(authEntriesWithHomeDomain(null)) - } - assertEquals("Invalid home domain", ex.message) + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(null)) + } + assertEquals("Invalid home domain", ex.message, "message for config: $configured") + } + } + + @Test + fun `test validate rejects an empty or scheme-only home_domain even with a bare wildcard`() { + every { sep45Config.homeDomains } returns listOf("*") + every { jwtService.encode(any()) } returns "header.payload.signature" + + listOf("", "http://", "HTTPS://").forEach { + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(it)) + } + assertEquals("Invalid home domain", ex.message, "message for: '$it'") + } + verify(exactly = 0) { jwtService.encode(any()) } } @Test diff --git a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt index 1cf372fd15..c833373477 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt @@ -263,6 +263,7 @@ class ClientDomainHelperTest { "evil.example\\x.sdp.stellar.org", "evil.example:80.sdp.stellar.org", "a b.sdp.stellar.org", + "fóo.sdp.stellar.org", ) .forEach { assertFalse(ClientDomainHelper.isDomainNameMatch(patterns, it), "should reject: $it") From a94e75a59234df9d6affa5ea21fb1c24d7c97631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:31:29 -0300 Subject: [PATCH 07/13] [ANCHOR-1400]: Reject a wildcard home domain match whose host has an empty or hyphen-edged label --- .../anchor/util/ClientDomainHelper.java | 24 +++++++++++- .../anchor/util/ClientDomainHelperTest.kt | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index f9bf722d14..bb568cd02d 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -160,7 +160,7 @@ public static Boolean isDomainNameMatch(List patternsAndDomains, String 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 { @@ -173,6 +173,28 @@ public static Boolean isDomainNameMatch(List patternsAndDomains, String return false; } + /** + * Checks that the host part of a domain name (without a trailing port) is made of non-empty + * labels 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 every label of 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); + } + for (String label : host.split("\\.", -1)) { + if (label.isEmpty() || label.startsWith("-") || label.endsWith("-")) { + return false; + } + } + return true; + } + /** * Retrieves the first fixed domain name from the provided list of patterns and domains. * diff --git a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt index c833373477..278499c872 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt @@ -291,4 +291,42 @@ class ClientDomainHelperTest { ) ) } + + @Test + fun `isDomainNameMatch rejects a wildcard match with an empty or hyphen-edged label`() { + val patterns = listOf("*.sdp.stellar.org") + listOf( + ".sdp.stellar.org", + "..sdp.stellar.org", + "...........sdp.stellar.org", + "-.sdp.stellar.org", + "-a.sdp.stellar.org", + "a-.sdp.stellar.org", + ) + .forEach { + assertFalse(ClientDomainHelper.isDomainNameMatch(patterns, it), "should reject: $it") + } + assertFalse( + ClientDomainHelper.isDomainNameMatch(listOf("*.example.com:8080"), ".example.com:8080") + ) + } + + @Test + fun `isDomainNameMatch accepts valid host labels under a wildcard`() { + val patterns = listOf("*.sdp.stellar.org") + listOf( + "foo-bar.sdp.stellar.org", + "a--b.sdp.stellar.org", + "xn--abc.sdp.stellar.org", + "_.sdp.stellar.org", + "foo_bar-1.sdp.stellar.org", + "a.b.sdp.stellar.org", + ) + .forEach { + assertTrue(ClientDomainHelper.isDomainNameMatch(patterns, it), "should accept: $it") + } + assertTrue( + ClientDomainHelper.isDomainNameMatch(listOf("*.example.com:8080"), "a.example.com:8080") + ) + } } From 8549a2bb93bc6e217097681254467ac492e9bb45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:31:45 -0300 Subject: [PATCH 08/13] [ANCHOR-1400]: Cover the wildcard home domain rejection at the SEP-45 level --- .../stellar/anchor/sep45/Sep45ServiceTest.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt index 595cc3e342..0767ffea71 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -410,6 +410,54 @@ class Sep45ServiceTest { } } + private val rejectedWildcardHomeDomains = + listOf( + "evil.example/x.sdp.stellar.org", + "evil.example?.sdp.stellar.org", + "evil.example#.sdp.stellar.org", + "u@evil.example.sdp.stellar.org", + "evil.example\\x.sdp.stellar.org", + "evil.example:80.sdp.stellar.org", + "a b.sdp.stellar.org", + ".sdp.stellar.org", + "..sdp.stellar.org", + "-.sdp.stellar.org", + "-a.sdp.stellar.org", + "a-.sdp.stellar.org", + ) + + @Test + fun `test getChallenge rejects structural and host-syntax values under a wildcard configuration`() { + every { sep45Config.homeDomains } returns listOf("*.sdp.stellar.org") + + rejectedWildcardHomeDomains.forEach { + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.getChallenge(challengeRequestFor(it)) + } + assertEquals("Invalid home domain", ex.message, "message for: $it") + } + + verify(exactly = 0) { nonceManager.create(any()) } + verify(exactly = 0) { stellarRpc.simulateTransaction(any()) } + } + + @Test + fun `test validate rejects structural and host-syntax values under a wildcard configuration`() { + every { sep45Config.homeDomains } returns listOf("*.sdp.stellar.org") + every { jwtService.encode(any()) } returns "header.payload.signature" + + rejectedWildcardHomeDomains.forEach { + val ex = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(it)) + } + assertEquals("Invalid home domain", ex.message, "message for: $it") + } + + verify(exactly = 0) { jwtService.encode(any()) } + } + private val rejectedHomeDomains = listOf( "localhost:8080/evil", From 38ae0bcf4ee9e553ae3fb56918830510adacb9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:54:38 -0300 Subject: [PATCH 09/13] [ANCHOR-1401]: Pin the reporter's wildcard home domain strings at the SEP-10 challenge request --- .../stellar/anchor/sep10/Sep10ServiceTest.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt index 48776baeb9..e727687e2f 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -1077,6 +1077,51 @@ internal class Sep10ServiceTest { 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 { 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 @LockAndMockStatic([NetUtil::class, ClientDomainHelper::class]) fun `Test create challenge with wildcard matched home domain success`() { From 48c62eb365bc882e2bb2b1efa46fdb73bdb92212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:56:36 -0300 Subject: [PATCH 10/13] [ANCHOR-1401]: Pin the reporter's wildcard home domain string at the SEP-10 challenge validation --- .../stellar/anchor/sep10/Sep10ServiceTest.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt index e727687e2f..9d63a98edf 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -1122,6 +1122,51 @@ internal class Sep10ServiceTest { } } + @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 { 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()) } + } + + @Test + fun `Test validateChallengeTransactionHomeDomain rejects a wildcard home domain spanning a path`() { + every { sep10Config.homeDomains } returns listOf(TEST_HOME_DOMAIN_PATTERN) + val challenge = + mockk { + every { matchedHomeDomain } returns "evil.com/x.wildcard.stellar.org" + } + + val ex = + assertThrows { + 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 { + 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`() { From facd5fc3b2d8f84d2b0d209bd1b1ed7787100cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:59:15 -0300 Subject: [PATCH 11/13] [ANCHOR-1401]: Reject a wildcard home domain match over the DNS label and host length limits --- .../anchor/util/ClientDomainHelper.java | 19 +++-- .../anchor/util/ClientDomainHelperTest.kt | 75 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index bb568cd02d..0fbb951f47 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -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); @@ -174,12 +177,12 @@ public static Boolean isDomainNameMatch(List patternsAndDomains, String } /** - * Checks that the host part of a domain name (without a trailing port) is made of non-empty - * labels that neither start nor end with a hyphen, so a wildcard cannot expand to something that - * is not a host name. + * 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 every label of the host part is well formed, false otherwise. + * @return true if the host part is well formed, false otherwise. */ private static boolean hasValidHostLabels(String domainName) { String host = domainName; @@ -187,8 +190,14 @@ private static boolean hasValidHostLabels(String domainName) { 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.startsWith("-") || label.endsWith("-")) { + if (label.isEmpty() + || label.length() > MAX_LABEL_LENGTH + || label.startsWith("-") + || label.endsWith("-")) { return false; } } diff --git a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt index 278499c872..b020577b77 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt @@ -329,4 +329,79 @@ class ClientDomainHelperTest { ClientDomainHelper.isDomainNameMatch(listOf("*.example.com:8080"), "a.example.com:8080") ) } + + /** A host of exactly [length] characters made of labels of at most 63, ending in [suffix]. */ + private fun hostOfLength(length: Int, suffix: String): String { + val labels = mutableListOf() + var remaining = length - suffix.length + while (remaining > 0) { + val take = minOf(63, remaining) + labels += "a".repeat(take) + remaining -= take + 1 + } + val host = labels.joinToString(".") + suffix + assertEquals(length, host.length) + return host + } + + @Test + fun `isDomainNameMatch rejects a wildcard label longer than 63 characters`() { + val patterns = listOf("*.sdp.stellar.org") + listOf("a".repeat(64) + ".sdp.stellar.org", "a." + "a".repeat(64) + ".sdp.stellar.org") + .forEach { + assertFalse(ClientDomainHelper.isDomainNameMatch(patterns, it), "should reject: $it") + } + } + + @Test + fun `isDomainNameMatch accepts a wildcard label of exactly 63 characters`() { + val patterns = listOf("*.sdp.stellar.org") + listOf("a".repeat(63) + ".sdp.stellar.org", "a." + "a".repeat(63) + ".sdp.stellar.org") + .forEach { + assertTrue(ClientDomainHelper.isDomainNameMatch(patterns, it), "should accept: $it") + } + } + + @Test + fun `isDomainNameMatch rejects a wildcard host longer than 253 characters`() { + val host = hostOfLength(254, ".sdp.stellar.org") + assertFalse(ClientDomainHelper.isDomainNameMatch(listOf("*.sdp.stellar.org"), host)) + } + + @Test + fun `isDomainNameMatch accepts a wildcard host of exactly 253 characters`() { + val host = hostOfLength(253, ".sdp.stellar.org") + assertTrue(ClientDomainHelper.isDomainNameMatch(listOf("*.sdp.stellar.org"), host)) + } + + @Test + fun `isDomainNameMatch measures the wildcard host length without the port`() { + val patterns = listOf("*.example.com:8080") + assertTrue( + ClientDomainHelper.isDomainNameMatch(patterns, hostOfLength(253, ".example.com") + ":8080") + ) + assertFalse( + ClientDomainHelper.isDomainNameMatch(patterns, hostOfLength(254, ".example.com") + ":8080") + ) + } + + @Test + fun `isDomainNameMatch rejects a value breaking a length limit and a label rule together`() { + val patterns = listOf("*.sdp.stellar.org") + listOf( + "-" + "a".repeat(63) + ".sdp.stellar.org", + "." + hostOfLength(253, ".sdp.stellar.org"), + ) + .forEach { + assertFalse(ClientDomainHelper.isDomainNameMatch(patterns, it), "should reject: $it") + } + } + + @Test + fun `isDomainNameMatch keeps exact matching free of length rules`() { + val longLabel = "a".repeat(64) + ".example.com" + val longHost = hostOfLength(254, ".example.com") + assertTrue(ClientDomainHelper.isDomainNameMatch(listOf(longLabel), longLabel)) + assertTrue(ClientDomainHelper.isDomainNameMatch(listOf(longHost), longHost)) + } } From a297107fbdb2789749c9a2b8af727fcd983d7e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 13:59:45 -0300 Subject: [PATCH 12/13] [ANCHOR-1401]: Cover the wildcard home domain length limits at the SEP-10 challenge request --- .../stellar/anchor/sep10/Sep10ServiceTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt index 9d63a98edf..a5eb9ad730 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -1122,6 +1122,29 @@ internal class Sep10ServiceTest { } } + @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 { 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) From 768b0705ebb973be1a734b4f3f166367e065434a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 30 Oct 2026 14:01:04 -0300 Subject: [PATCH 13/13] [ANCHOR-1401]: Cover the wildcard home domain length limits at the SEP-45 challenge and validation --- .../stellar/anchor/sep45/Sep45ServiceTest.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt index 0767ffea71..805952f07d 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -458,6 +458,33 @@ class Sep45ServiceTest { verify(exactly = 0) { jwtService.encode(any()) } } + @Test + fun `test getChallenge and validate reject an over-long wildcard home domain`() { + every { sep45Config.homeDomains } returns listOf("*.sdp.stellar.org") + every { jwtService.encode(any()) } returns "header.payload.signature" + 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 { + val challengeEx = + assertThrows(BadRequestException::class.java) { + sep45Service.getChallenge(challengeRequestFor(it)) + } + assertEquals("Invalid home domain", challengeEx.message, "getChallenge message for: $it") + + val validateEx = + assertThrows(BadRequestException::class.java) { + sep45Service.validate(authEntriesWithHomeDomain(it)) + } + assertEquals("Invalid home domain", validateEx.message, "validate message for: $it") + } + + verify(exactly = 0) { nonceManager.create(any()) } + verify(exactly = 0) { stellarRpc.simulateTransaction(any()) } + verify(exactly = 0) { jwtService.encode(any()) } + } + private val rejectedHomeDomains = listOf( "localhost:8080/evil",