From 9a423d3ba5686b1020ec050aa178db3819caf2db Mon Sep 17 00:00:00 2001 From: Jiahui Hu Date: Mon, 30 Mar 2026 10:19:31 -0400 Subject: [PATCH 1/5] [ANCHOR-1173] Fix blind SSRF in SEP-10 client_domain resolution (#1915) ### Description Add private network validation to `ClientDomainHelper.fetchSigningKeyFromClientDomain()` to block SSRF via user-controlled `client_domain` parameter ### Context - TODO: describe why this change was made ### Testing - `./gradlew test` - New unit tests for all blocked address ranges (loopback, localhost, link-local, 10.x, 172.16.x, 192.168.x) ### Documentation N/A ### Known limitations N/A --- .../anchor/util/ClientDomainHelper.java | 58 ++++++++++++ .../stellar/anchor/sep10/Sep10ServiceTest.kt | 28 +++++- .../anchor/util/ClientDomainHelperTest.kt | 93 +++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt 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 078ebb7d70..e27a949820 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -4,6 +4,9 @@ import static org.stellar.anchor.util.Log.infoF; import java.io.IOException; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -23,6 +26,13 @@ public class ClientDomainHelper { */ public static String fetchSigningKeyFromClientDomain(String clientDomain, boolean allowHttpRetry) throws SepException { + // allowHttpRetry is true for non-public networks (testnet/dev) and false for mainnet. + // We only enforce SSRF protection on public network because test/dev environments + // legitimately use localhost and internal addresses as client_domain. + if (!allowHttpRetry) { + validateDomainNotPrivateNetwork(clientDomain); + } + String clientSigningKey = ""; String url = "https://" + clientDomain + "/.well-known/stellar.toml"; try { @@ -119,4 +129,52 @@ public static String getDefaultDomainName(List patternsAndDomains) { } return null; } + + /** + * Validates that a client domain does not resolve to a private, loopback, or link-local IP + * address. This prevents SSRF attacks where an attacker supplies a domain that resolves to + * internal network addresses. + * + * @param clientDomain The domain to validate. + * @throws SepException if the domain resolves to a non-public IP address. + */ + public static void validateDomainNotPrivateNetwork(String clientDomain) throws SepException { + String hostname = extractHostname(clientDomain); + + try { + InetAddress[] addresses = InetAddress.getAllByName(hostname); + for (InetAddress address : addresses) { + if (address.isLoopbackAddress() + || address.isSiteLocalAddress() + || address.isLinkLocalAddress() + || address.isAnyLocalAddress()) { + infoF("client_domain {} resolves to non-public address {}", clientDomain, address); + throw new SepException("client_domain resolves to a non-public address"); + } + } + } catch (UnknownHostException e) { + infoF("client_domain {} could not be resolved", clientDomain); + throw new SepException("client_domain could not be resolved"); + } + } + + /** + * Extracts the hostname from a client domain string, correctly handling IPv6 literals (e.g. + * [::1]:8080) and domains with ports. + */ + static String extractHostname(String clientDomain) { + try { + URI uri = new URI("https://" + clientDomain); + String host = uri.getHost(); + if (host != null) { + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return host; + } + } catch (Exception ignored) { + // Fall through + } + return clientDomain; + } } 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 1350ed2c7a..3c95906c7d 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -156,8 +156,13 @@ internal class Sep10ServiceTest { @ParameterizedTest @CsvSource(value = ["true,test.client.stellar.org", "false,test.client.stellar.org", "false,"]) - @LockAndMockStatic([NetUtil::class, Sep10Challenge::class]) + @LockAndMockStatic([NetUtil::class, Sep10Challenge::class, ClientDomainHelper::class]) fun `test create challenge ok`(clientAttributionRequired: Boolean, clientDomain: String?) { + every { ClientDomainHelper.validateDomainNotPrivateNetwork(any()) } just Runs + every { ClientDomainHelper.fetchSigningKeyFromClientDomain(any(), any()) } answers + { + callOriginal() + } every { NetUtil.fetch(any()) } returns TEST_CLIENT_TOML every { sep10Config.isClientAttributionRequired } returns clientAttributionRequired @@ -298,8 +303,13 @@ internal class Sep10ServiceTest { } @Test - @LockAndMockStatic([NetUtil::class]) + @LockAndMockStatic([NetUtil::class, ClientDomainHelper::class]) fun `Test create challenge with wildcard matched home domain success`() { + every { ClientDomainHelper.validateDomainNotPrivateNetwork(any()) } just Runs + every { ClientDomainHelper.fetchSigningKeyFromClientDomain(any(), any()) } answers + { + callOriginal() + } every { NetUtil.fetch(any()) } returns TEST_CLIENT_TOML val cr = ChallengeRequest.builder() @@ -314,8 +324,13 @@ internal class Sep10ServiceTest { } @Test - @LockAndMockStatic([NetUtil::class, Sep10Challenge::class]) + @LockAndMockStatic([NetUtil::class, Sep10Challenge::class, ClientDomainHelper::class]) fun `Test create challenge request with empty memo`() { + every { ClientDomainHelper.validateDomainNotPrivateNetwork(any()) } just Runs + every { ClientDomainHelper.fetchSigningKeyFromClientDomain(any(), any()) } answers + { + callOriginal() + } every { NetUtil.fetch(any()) } returns TEST_CLIENT_TOML val cr = ChallengeRequest.builder() @@ -456,8 +471,13 @@ internal class Sep10ServiceTest { } @Test - @LockAndMockStatic([NetUtil::class]) + @LockAndMockStatic([NetUtil::class, ClientDomainHelper::class]) fun `test getClientAccountId failure`() { + every { ClientDomainHelper.validateDomainNotPrivateNetwork(any()) } just Runs + every { ClientDomainHelper.fetchSigningKeyFromClientDomain(any(), any()) } answers + { + callOriginal() + } every { NetUtil.fetch(any()) } returns " NETWORK_PASSPHRASE=\"Public Global Stellar Network ; September 2015\"\n" diff --git a/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt new file mode 100644 index 0000000000..e7e34594a1 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientDomainHelperTest.kt @@ -0,0 +1,93 @@ +package org.stellar.anchor.util + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.stellar.anchor.api.exception.SepException + +class ClientDomainHelperTest { + + @Test + fun `test loopback address is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("127.0.0.1") + } + } + + @Test + fun `test localhost is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("localhost") + } + } + + @Test + fun `test link-local address is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("169.254.169.254") + } + } + + @Test + fun `test private 10-x address is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("10.0.0.1") + } + } + + @Test + fun `test private 192-168 address is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("192.168.1.1") + } + } + + @Test + fun `test private 172-16 address is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("172.16.0.1") + } + } + + @Test + fun `test domain with port strips port before resolving`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork("127.0.0.1:8080") + } + } + + @Test + fun `test unresolvable domain is rejected`() { + assertThrows(SepException::class.java) { + ClientDomainHelper.validateDomainNotPrivateNetwork( + "this-domain-does-not-exist-xyz123.invalid" + ) + } + } + + @Test + fun `test public address is accepted`() { + assertDoesNotThrow { ClientDomainHelper.validateDomainNotPrivateNetwork("8.8.8.8") } + } + + @Test + fun `test extractHostname with plain domain`() { + assertEquals("example.com", ClientDomainHelper.extractHostname("example.com")) + } + + @Test + fun `test extractHostname with domain and port`() { + assertEquals("example.com", ClientDomainHelper.extractHostname("example.com:8080")) + } + + @Test + fun `test extractHostname with bracketed IPv6`() { + assertEquals("::1", ClientDomainHelper.extractHostname("[::1]")) + } + + @Test + fun `test extractHostname with bracketed IPv6 and port`() { + assertEquals("::1", ClientDomainHelper.extractHostname("[::1]:8080")) + } +} From f4d63f959e7509566a4443cd2ebb1fd56c453534 Mon Sep 17 00:00:00 2001 From: Jiahui Hu Date: Thu, 2 Apr 2026 21:00:50 -0400 Subject: [PATCH 2/5] [ANCHOR-1179] Fix SEP-12 IDOR via transaction_id in customer endpoints (#1916) ### Description Verify the transaction's creator matches the caller's JWT token before resolving customer data ### Context An authenticated user could access or modify any customer's KYC data by providing another user's `transaction_id` in SEP-12 GET/PUT `/sep12/customer` requests. ### Testing - `./gradlew test` - Added test for mismatched/null/matching creator ### Documentation N/A ### Known limitations N/A --- .../stellar/anchor/sep12/Sep12Service.java | 34 ++++++----- .../stellar/anchor/sep12/Sep12ServiceTest.kt | 56 +++++++++++++++---- .../platform/e2etest/Sep6End2EndTest.kt | 6 +- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java index 75e6350c6d..21e9019541 100644 --- a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java +++ b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java @@ -51,24 +51,8 @@ public Sep12Service( Log.info("Sep12Service initialized."); } - public void populateRequestFromTransactionId(Sep12CustomerRequestBase requestBase) - throws SepNotFoundException { - if (requestBase.getTransactionId() != null) { - try { - GetTransactionResponse txn = - platformApiClient.getTransaction(requestBase.getTransactionId()); - requestBase.setAccount(txn.getCustomers().getSender().getAccount()); - requestBase.setMemo(txn.getCustomers().getSender().getMemo()); - } catch (Exception e) { - throw new SepNotFoundException("The transaction specified does not exist"); - } - } - } - public Sep12GetCustomerResponse getCustomer(WebAuthJwt token, Sep12GetCustomerRequest request) throws AnchorException { - populateRequestFromTransactionId(request); - validateGetOrPutRequest(request, token); if (request.getAccount() == null && token.getAccount() != null) { request.setAccount(token.getAccount()); @@ -85,8 +69,6 @@ public Sep12GetCustomerResponse getCustomer(WebAuthJwt token, Sep12GetCustomerRe public Sep12PutCustomerResponse putCustomer(WebAuthJwt token, Sep12PutCustomerRequest request) throws AnchorException { - populateRequestFromTransactionId(request); - validateGetOrPutRequest(request, token); if (request.getAccount() == null && token.getAccount() != null) { @@ -179,6 +161,22 @@ void validateGetOrPutRequest(Sep12CustomerRequestBase requestBase, WebAuthJwt to // sep31-sender, sep-31-receiver) to get the customer account and memo GetTransactionResponse txn = platformApiClient.getTransaction(requestBase.getTransactionId()); + + // Verify transaction ownership. + // SEP-31 stores the muxed M-address in creator.account, while SEP-6/24 store the + // base G-address. Match against the corresponding token field accordingly. + StellarId creator = txn.getCreator(); + String creatorAccount = creator != null ? creator.getAccount() : null; + String tokenAccount = + creatorAccount != null && creatorAccount.startsWith("M") + ? token.getMuxedAccount() + : token.getAccount(); + if (creator == null + || !Objects.equals(creatorAccount, tokenAccount) + || !Objects.equals(creator.getMemo(), token.getAccountMemo())) { + throw new Exception("ownership check failed"); + } + StellarId customer = "sep31-receiver".equals(requestBase.getType()) ? txn.getCustomers().getReceiver() diff --git a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt index 51ac0de608..e5969426fb 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt @@ -222,9 +222,10 @@ class Sep12ServiceTest { } @Test - fun `test get and put sets request account and memo using transaction`() { + fun `test transaction ownership check rejects mismatched creator`() { val transaction = GetTransactionResponse.builder() + .creator(StellarId.builder().account("GOTHER_ACCOUNT").build()) .customers( Customers.builder() .sender(StellarId.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).build()) @@ -233,16 +234,50 @@ class Sep12ServiceTest { .build() every { platformApiClient.getTransaction(any()) } returns transaction - val putRequest = Sep12PutCustomerRequest.builder().transactionId(TEST_TRANSACTION_ID).build() - assertDoesNotThrow { sep12Service.populateRequestFromTransactionId(putRequest) } - assertEquals(TEST_ACCOUNT, putRequest.account) - assertEquals(TEST_MEMO, putRequest.memo) + val request = Sep12GetCustomerRequest.builder().transactionId(TEST_TRANSACTION_ID).build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + val ex: SepException = assertThrows { sep12Service.validateGetOrPutRequest(request, jwtToken) } + assertInstanceOf(SepNotAuthorizedException::class.java, ex) + assertEquals("The transaction specified does not exist", ex.message) + } + + @Test + fun `test transaction ownership check rejects null creator`() { + val transaction = + GetTransactionResponse.builder() + .customers( + Customers.builder() + .sender(StellarId.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).build()) + .build() + ) + .build() + every { platformApiClient.getTransaction(any()) } returns transaction + + val request = Sep12GetCustomerRequest.builder().transactionId(TEST_TRANSACTION_ID).build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + val ex: SepException = assertThrows { sep12Service.validateGetOrPutRequest(request, jwtToken) } + assertInstanceOf(SepNotAuthorizedException::class.java, ex) + assertEquals("The transaction specified does not exist", ex.message) + } - val getRequestBase = - Sep12GetCustomerRequest.builder().transactionId(TEST_TRANSACTION_ID).build() - assertDoesNotThrow { sep12Service.populateRequestFromTransactionId(getRequestBase) } - assertEquals(TEST_ACCOUNT, getRequestBase.account) - assertEquals(TEST_MEMO, getRequestBase.memo) + @Test + fun `test transaction ownership check allows matching creator`() { + val transaction = + GetTransactionResponse.builder() + .creator(StellarId.builder().account(TEST_ACCOUNT).build()) + .customers( + Customers.builder() + .sender(StellarId.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).build()) + .build() + ) + .build() + every { platformApiClient.getTransaction(any()) } returns transaction + + val request = Sep12GetCustomerRequest.builder().transactionId(TEST_TRANSACTION_ID).build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + assertDoesNotThrow { sep12Service.validateGetOrPutRequest(request, jwtToken) } + assertEquals(TEST_ACCOUNT, request.account) + assertEquals(TEST_MEMO, request.memo) } @Test @@ -324,6 +359,7 @@ class Sep12ServiceTest { every { eventSession.publish(capture(kycUpdateEventSlot)) } returns Unit every { platformApiClient.getTransaction(any()) } returns GetTransactionResponse.builder() + .creator(StellarId.builder().account(TEST_ACCOUNT).build()) .customers( Customers.builder() .sender(StellarId.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).build()) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep6End2EndTest.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep6End2EndTest.kt index 58246eff44..6b48999daf 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep6End2EndTest.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep6End2EndTest.kt @@ -31,7 +31,6 @@ import org.stellar.reference.wallet.WalletServerClient import org.stellar.sdk.Asset import org.stellar.sdk.KeyPair import org.stellar.sdk.MuxedAccount -import org.stellar.walletsdk.anchor.customer import org.stellar.walletsdk.asset.IssuedAssetId @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -119,9 +118,8 @@ open class Sep6End2EndTest : IntegrationTestBase(TestConfig()) { // Supply missing KYC info to continue with the transaction val additionalRequiredFields = - anchor - .customer(token) - .get(transactionId = deposit.id) + wallet.sep12 + .getCustomer(transactionId = deposit.id)!! .fields ?.filter { it.key != null && it.value?.optional == false } ?.map { it.key!! } From b1ffeb48c3d45308b5367e8b2ecc988c3c7980de Mon Sep 17 00:00:00 2001 From: Jiahui Hu Date: Wed, 8 Apr 2026 15:04:37 -0400 Subject: [PATCH 3/5] [ANCHOR-1185] Add XDR size validation in SEP-10 and SEP-45 auth endpoint (#1917) ### Description - Add 50KB size limit on `transaction` field in SEP-10 POST `/auth` before XDR parsing - Reduce existing SEP-45 `authorization_entries` size limit from 100KB to 50KB ### Context Valid SEP-10/SEP-45 auth payloads are small (a few KB). Limiting input size before XDR deserialization prevents unnecessary memory allocation from oversized payloads. ### Testing - `./gradlew test` ### Documentation N/A ### Known limitations N/A --- .../java/org/stellar/anchor/sep10/Sep10Service.java | 6 +++++- .../java/org/stellar/anchor/sep45/Sep45Service.java | 2 +- .../org/stellar/anchor/sep10/Sep10ServiceTest.kt | 8 ++++++++ .../org/stellar/anchor/sep45/Sep45ServiceTest.kt | 10 ++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java b/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java index b27165afcb..4876f77047 100644 --- a/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java +++ b/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java @@ -511,10 +511,14 @@ String fetchClientDomain(ChallengeTransaction challenge) { ChallengeTransaction parseChallenge(ValidationRequest request) throws SepValidationException { - if (request == null || request.getTransaction() == null) { + if (request == null || isEmpty(request.getTransaction())) { throw new SepValidationException("{transaction} is required."); } + if (request.getTransaction().length() > 50_000) { + throw new SepValidationException("transaction exceeds maximum allowed size"); + } + String transaction = request.getTransaction(); Network network = new Network(stellarNetworkConfig.getStellarNetworkPassphrase()); String homeDomain = extractHomeDomainFromChallengeXdr(transaction, network); 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 96681e65fb..97cc8a5486 100644 --- a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java +++ b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java @@ -180,7 +180,7 @@ public ValidationResponse validate(ValidationRequest request) throws AnchorExcep throw new BadRequestException("authorization_entries is required"); } - if (request.getAuthorizationEntries().length() > 100_000) { + if (request.getAuthorizationEntries().length() > 50_000) { throw new BadRequestException("authorization_entries exceeds maximum allowed size"); } 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 3c95906c7d..190c859750 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -280,6 +280,14 @@ internal class Sep10ServiceTest { assertThrows { sep10Service.validateChallenge(vr) } } + @Test + fun `Test validate challenge rejects oversized transaction`() { + val vr = ValidationRequest() + vr.transaction = "A".repeat(50_001) + val ex = assertThrows { sep10Service.validateChallenge(vr) } + assertEquals("transaction exceeds maximum allowed size", ex.message) + } + @Test @LockAndMockStatic([Sep10Challenge::class]) fun `Test validate challenge with bad home domain failure`() { 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 f355964fee..1c25cb7136 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep45/Sep45ServiceTest.kt @@ -192,6 +192,16 @@ class Sep45ServiceTest { assertEquals("authorization_entries is required", ex.message) } + @Test + fun `test validate throws BadRequestException when auth entries exceed max size`() { + val validationRequest = + ValidationRequest.builder().authorizationEntries("A".repeat(50_001)).build() + + val ex = + assertThrows(BadRequestException::class.java) { sep45Service.validate(validationRequest) } + assertEquals("authorization_entries exceeds maximum allowed size", ex.message) + } + @Test fun `test validate throws BadRequestException when auth entries list empty`() { val emptyAuth = SorobanAuthorizationEntries(arrayOf()) From 73edd3f05d62119fb2fb0388950ce126cb3707af Mon Sep 17 00:00:00 2001 From: Jiahui Hu Date: Wed, 8 Apr 2026 16:31:44 -0400 Subject: [PATCH 4/5] [Chore] Bump version to 4.2.1 (#1919) ### Description This bumps the version to 4.2.1 ### Context Patch release ### Testing - `./gradlew test` ### Documentation N/A ### Known limitations N/A --- README.md | 2 +- build.gradle.kts | 2 +- service-runner/src/main/resources/version-info.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3baedf3b74..457fb13572 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![License](https://badgen.net/badge/license/Apache%202/blue?icon=github&label=License)](https://github.com/stellar/anchor-platform/blob/develop/LICENSE) [![GitHub Version](https://badgen.net/github/release/stellar/anchor-platform?icon=github&label=Latest%20release)](https://github.com/stellar/anchor-platform/releases) -[![Docker](https://badgen.net/badge/Latest%20Release/v4.2.0/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.2.0) +[![Docker](https://badgen.net/badge/Latest%20Release/v4.2.1/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.2.1) ![Develop Branch](https://github.com/stellar/anchor-platform/actions/workflows/on_push_to_develop.yml/badge.svg?branch=develop)
diff --git a/build.gradle.kts b/build.gradle.kts index 509fe8da90..5c72e89cc4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -213,7 +213,7 @@ subprojects { allprojects { group = "org.stellar.anchor-sdk" - version = "4.2.0" + version = "4.2.1" tasks.jar { manifest { diff --git a/service-runner/src/main/resources/version-info.properties b/service-runner/src/main/resources/version-info.properties index 1171e9d55f..80affa40aa 100644 --- a/service-runner/src/main/resources/version-info.properties +++ b/service-runner/src/main/resources/version-info.properties @@ -1 +1 @@ -version=4.2.0 +version=4.2.1 From a1d7906adfb61bbacefe7aeb227ddbef0674bcc5 Mon Sep 17 00:00:00 2001 From: Jiahui Hu Date: Wed, 8 Apr 2026 17:14:26 -0400 Subject: [PATCH 5/5] [ACNHOT-1186] Fix `MEMO_ID` validation to support full Stellar uint64 range (#1918) ### Description - Fix MEMO_ID validation to support the full Stellar uint64 range (0 to 18,446,744,073,709,551,615) - Replace Long.parseLong and Long.longValue() with BigInteger for MEMO_ID parsing and conversion - Consolidate memo ID creation into a shared MemoHelper.makeMemoId() method ### Context Partner reported that SEP-24 requests with refund_memo 11872666534918305457 were rejected with "Invalid Memo" due to this refund_memo value above Java's `Long.MAX_VALUE `(9,223,372,036,854,775,807) Stellar protocol defines `MEMO_ID` as uint64, but the platform was using Java's signed long for parsing, which only supports half the range. The same issue existed in SEP-10 memo validation, `xdrMemoToString` (used by the payment observer for memo matching), and muxed account memo handling in `DefaultPaymentListener` ### Testing - ./gradlew test - Added new unit tests to cover all cases ### Documentation N/A ### Known limitations N/A --- .../stellar/anchor/sep10/Sep10Service.java | 10 ++--- .../org/stellar/anchor/util/MemoHelper.java | 19 ++++++++- .../stellar/anchor/sep10/Sep10ServiceTest.kt | 2 +- .../org/stellar/anchor/util/MemoHelperTest.kt | 41 +++++++++++++++++++ .../stellar/DefaultPaymentListener.java | 2 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java b/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java index 4876f77047..677094d2a0 100644 --- a/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java +++ b/core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java @@ -2,6 +2,7 @@ import static java.lang.String.format; import static org.stellar.anchor.util.Log.*; +import static org.stellar.anchor.util.MemoHelper.makeMemoId; import static org.stellar.anchor.util.MetricConstants.SEP10_CHALLENGE_CREATED; import static org.stellar.anchor.util.MetricConstants.SEP10_CHALLENGE_VALIDATED; import static org.stellar.anchor.util.StringHelper.isEmpty; @@ -199,15 +200,10 @@ Transaction newChallenge(ChallengeRequest request, String clientSigningKey, Memo @Override public Memo validateChallengeRequestMemo(ChallengeRequest request) throws SepException { - // Validate memo. It should be 64-bit positive integer if not null. + // Validate memo. It should be a positive uint64 integer if not null. try { if (request.getMemo() != null) { - long memoLong = Long.parseUnsignedLong(request.getMemo()); - if (memoLong <= 0) { - infoF("Invalid memo value: {}", request.getMemo()); - throw new SepValidationException(format("Invalid memo value: %s", request.getMemo())); - } - return new MemoId(memoLong); + return makeMemoId(request.getMemo()); } else { return null; } diff --git a/core/src/main/java/org/stellar/anchor/util/MemoHelper.java b/core/src/main/java/org/stellar/anchor/util/MemoHelper.java index bd8777a0cb..061a347971 100644 --- a/core/src/main/java/org/stellar/anchor/util/MemoHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/MemoHelper.java @@ -3,6 +3,7 @@ import static org.stellar.anchor.util.StringHelper.isEmpty; import static org.stellar.sdk.xdr.MemoType.*; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.Base64; import org.apache.commons.codec.DecoderException; @@ -78,7 +79,7 @@ public static Memo makeMemo(String memo, MemoType memoType) throws SepException try { switch (memoType) { case MEMO_ID: - return new MemoId(Long.parseLong(memo)); + return makeMemoId(memo); case MEMO_TEXT: return new MemoText(memo); case MEMO_HASH: @@ -99,6 +100,20 @@ public static Memo makeMemo(String memo, MemoType memoType) throws SepException } } + /** + * Creates a MemoId from a string, supporting the full uint64 range (1 to + * 18,446,744,073,709,551,615) as defined by the Stellar protocol. + */ + public static MemoId makeMemoId(String memo) { + try { + return new MemoId(new BigInteger(memo)); + } catch (IllegalArgumentException e) { + NumberFormatException nfe = new NumberFormatException(e.getMessage()); + nfe.initCause(e); + throw nfe; + } + } + public static String convertBase64ToHex(String memo) { return Hex.encodeHexString(Base64.getDecoder().decode(memo.getBytes())); } @@ -160,7 +175,7 @@ public static String xdrMemoToString(org.stellar.sdk.xdr.Memo memoXdr) { return switch (memoXdr.getDiscriminant()) { case MEMO_NONE -> null; // No memo case MEMO_TEXT -> new String(memoXdr.getText().getBytes(), StandardCharsets.UTF_8); - case MEMO_ID -> String.valueOf(memoXdr.getId().getUint64().getNumber().longValue()); + case MEMO_ID -> memoXdr.getId().getUint64().getNumber().toString(); case MEMO_HASH, MEMO_RETURN -> Base64.getEncoder().encodeToString(memoXdr.getHash().getHash()); }; 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 190c859750..61fde73a89 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -401,7 +401,7 @@ internal class Sep10ServiceTest { } @ParameterizedTest - @ValueSource(strings = ["ABC", "12AB", "-1", "0", Integer.MIN_VALUE.toString()]) + @ValueSource(strings = ["ABC", "12AB", "-1", Integer.MIN_VALUE.toString()]) fun `test createChallenge() with bad memo`(badMemo: String) { every { sep10Config.isClientAttributionRequired } returns false val cr = diff --git a/core/src/test/kotlin/org/stellar/anchor/util/MemoHelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/MemoHelperTest.kt index 856ce2642d..06cc57df0c 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/MemoHelperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/MemoHelperTest.kt @@ -63,6 +63,47 @@ internal class MemoHelperTest { ) } + @Test + fun `test makeMemoId with valid uint64 values`() { + // Small value - should not throw + makeMemoId("1234") + + // Max signed long - should not throw + makeMemoId("9223372036854775807") + + // Above signed long max (valid uint64) + makeMemoId("11872666534918305457") + + // Max uint64 - should not throw + makeMemoId("18446744073709551615") + } + + @Test + fun `test makeMemo rejects invalid memo id values`() { + assertThrows { makeMemo("-1", "id") } + assertThrows { makeMemo("18446744073709551616", "id") } + assertThrows { makeMemo("abc", "id") } + } + + @Test + fun `test xdrMemoToString round-trip with uint64 memo ids`() { + // Round-trip: makeMemoId -> toXdr -> xdrMemoToString should preserve the original value + val testValues = + listOf("1234", "9223372036854775807", "11872666534918305457", "18446744073709551615") + for (value in testValues) { + val memo = makeMemoId(value) + val xdr = toXdr(memo) + assertEquals(value, xdrMemoToString(xdr)) + } + } + + @Test + fun `test xdrMemoToString with null and none`() { + assertEquals(null, xdrMemoToString(null)) + val noneMemo = toXdr(null) + assertEquals(null, xdrMemoToString(noneMemo)) + } + @Test fun `test toXdr()`() { val memoId: MemoId = makeMemo("123", MEMO_ID) as MemoId diff --git a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java index 8ab61e895a..03f378723c 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java @@ -149,7 +149,7 @@ void processAndDispatchLedgerPayment( && accountType(ledgerPayment.getTo()) == Muxed) { MuxedAccount muxedAccount = new MuxedAccount(ledgerPayment.getTo()); toAccount = muxedAccount.getAccountId(); - memo = Memo.id(Objects.requireNonNull(muxedAccount.getMuxedId()).longValue()); + memo = Memo.id(Objects.requireNonNull(muxedAccount.getMuxedId())); } else { toAccount = ledgerPayment.getTo(); memo = Memo.fromXdr(ledgerTransaction.getMemo());