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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)

<div style="text-align: center">
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ subprojects {

allprojects {
group = "org.stellar.anchor-sdk"
version = "4.2.0"
version = "4.2.1"

tasks.jar {
manifest {
Expand Down
16 changes: 8 additions & 8 deletions core/src/main/java/org/stellar/anchor/sep10/Sep10Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -511,10 +507,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);
Expand Down
34 changes: 16 additions & 18 deletions core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
58 changes: 58 additions & 0 deletions core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -119,4 +129,52 @@ public static String getDefaultDomainName(List<String> 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;
}
}
19 changes: 17 additions & 2 deletions core/src/main/java/org/stellar/anchor/util/MemoHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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()));
}
Expand Down Expand Up @@ -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());
};
Expand Down
38 changes: 33 additions & 5 deletions core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,6 +280,14 @@ internal class Sep10ServiceTest {
assertThrows<SepValidationException> { sep10Service.validateChallenge(vr) }
}

@Test
fun `Test validate challenge rejects oversized transaction`() {
val vr = ValidationRequest()
vr.transaction = "A".repeat(50_001)
val ex = assertThrows<SepValidationException> { sep10Service.validateChallenge(vr) }
assertEquals("transaction exceeds maximum allowed size", ex.message)
}

@Test
@LockAndMockStatic([Sep10Challenge::class])
fun `Test validate challenge with bad home domain failure`() {
Expand All @@ -298,8 +311,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()
Expand All @@ -314,8 +332,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()
Expand Down Expand Up @@ -378,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 =
Expand Down Expand Up @@ -456,8 +479,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"

Expand Down
Loading
Loading