diff --git a/build.gradle.kts b/build.gradle.kts index 50e1166762..c850b30da5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -135,7 +135,7 @@ subprojects { allprojects { group = "org.stellar.anchor-sdk" - version = "2.1.2" + version = "2.1.3" tasks.jar { manifest { diff --git a/core/src/main/java/org/stellar/anchor/sep1/Sep1Service.java b/core/src/main/java/org/stellar/anchor/sep1/Sep1Service.java index 03c6813b01..9edf5efcd3 100644 --- a/core/src/main/java/org/stellar/anchor/sep1/Sep1Service.java +++ b/core/src/main/java/org/stellar/anchor/sep1/Sep1Service.java @@ -1,6 +1,5 @@ package org.stellar.anchor.sep1; -import static org.stellar.anchor.util.Log.debug; import static org.stellar.anchor.util.Log.debugF; import static org.stellar.anchor.util.MetricConstants.SEP1_TOML_ACCESSED; @@ -27,10 +26,9 @@ public class Sep1Service { */ public Sep1Service(Sep1Config sep1Config) throws IOException, InvalidConfigException { if (sep1Config.isEnabled()) { - debug("sep1Config:", sep1Config); switch (sep1Config.getType()) { case STRING: - debug("reading stellar.toml from config[sep1.toml.value]"); + debugF("reading stellar.toml from config[sep1.toml.value]"); tomlValue = sep1Config.getValue(); break; case FILE: diff --git a/core/src/main/java/org/stellar/anchor/util/NetUtil.java b/core/src/main/java/org/stellar/anchor/util/NetUtil.java index 35e2e1ee06..e8c3545146 100644 --- a/core/src/main/java/org/stellar/anchor/util/NetUtil.java +++ b/core/src/main/java/org/stellar/anchor/util/NetUtil.java @@ -7,18 +7,39 @@ import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL; -import java.util.Objects; import okhttp3.Call; import okhttp3.Request; import okhttp3.Response; public class NetUtil { + + /** + * Fetches the content from the specified URL using an HTTP GET request. + * + *

This method expects a response body to be present in the HTTP response. If the response is + * unsuccessful (i.e., not a 2xx status code) or if the response body is null, an IOException will + * be thrown. + * + * @param url The URL to fetch content from. + * @return The content of the response body as a string. + * @throws IOException If the response is unsuccessful, or if the response body is null. + */ public static String fetch(String url) throws IOException { + Request request = OkHttpUtil.buildGetRequest(url); Response response = getCall(request).execute(); - if (response.body() == null) return ""; - return Objects.requireNonNull(response.body()).string(); + // Check if response was unsuccessful (ie not status code 2xx) and throw IOException + if (!response.isSuccessful()) { + throw new IOException(response.toString()); + } + + // Since fetch expects a response body, we will throw IOException if its null + if (response.body() == null) { + throw new IOException(response.toString()); + } + + return response.body().string(); } @SuppressWarnings("BooleanMethodIsAlwaysInverted") diff --git a/core/src/main/java/org/stellar/anchor/util/Sep1Helper.java b/core/src/main/java/org/stellar/anchor/util/Sep1Helper.java index 517a0864f3..9aca02d869 100644 --- a/core/src/main/java/org/stellar/anchor/util/Sep1Helper.java +++ b/core/src/main/java/org/stellar/anchor/util/Sep1Helper.java @@ -1,26 +1,39 @@ package org.stellar.anchor.util; +import static org.stellar.anchor.util.Log.*; + import com.moandjiezana.toml.Toml; import java.io.IOException; -import java.net.URL; +import org.stellar.anchor.api.exception.InvalidConfigException; public class Sep1Helper { public static TomlContent readToml(String url) throws IOException { - return new TomlContent(new URL(url)); + try { + String tomlValue = NetUtil.fetch(url); + return new TomlContent(tomlValue); + } catch (IOException e) { + String obfuscatedMessage = + String.format("An error occurred while fetching the TOML from %s", url); + Log.error(e.toString()); + throw new IOException(obfuscatedMessage); // Preserve the original exception as the cause + } } - public static TomlContent parse(String tomlString) { - return new TomlContent(tomlString); + public static TomlContent parse(String tomlString) throws InvalidConfigException { + try { + return new TomlContent(tomlString); + } catch (Exception e) { + // Obfuscate the message and rethrow + String obfuscatedMessage = "Failed to parse TOML content. Invalid Config."; + Log.error(e.toString()); // Log the parsing exception + throw new InvalidConfigException( + obfuscatedMessage); // Preserve the original exception as the cause + } } public static class TomlContent { private final Toml toml; - TomlContent(URL url) throws IOException { - String tomlValue = NetUtil.fetch(url.toString()); - toml = new Toml().read(tomlValue); - } - TomlContent(String tomlString) { toml = new Toml().read(tomlString); } diff --git a/core/src/test/kotlin/org/stellar/anchor/util/NetUtilTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/NetUtilTest.kt index 593d890aa7..fe8fd63808 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/NetUtilTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/NetUtilTest.kt @@ -4,11 +4,13 @@ package org.stellar.anchor.util import io.mockk.* import io.mockk.impl.annotations.MockK +import java.io.IOException import java.net.MalformedURLException import okhttp3.Response import okhttp3.ResponseBody import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -36,15 +38,17 @@ internal class NetUtilTest { } @Test - fun `test fetch()`() { + fun `test fetch successful response`() { mockkStatic(NetUtil::class) every { NetUtil.getCall(any()) } returns mockCall every { mockCall.execute() } returns mockResponse + every { mockResponse.isSuccessful } returns true every { mockResponse.body } returns mockResponseBody every { mockResponseBody.string() } returns "result" val result = NetUtil.fetch("http://hello") - assert(result.equals("result")) + assertEquals("result", result) + verify { NetUtil.getCall(any()) mockCall.execute() @@ -52,15 +56,24 @@ internal class NetUtilTest { } @Test - fun `test fetch() throws exception`() { + fun `test fetch unsuccessful response`() { + mockkStatic(NetUtil::class) + every { NetUtil.getCall(any()) } returns mockCall + every { mockCall.execute() } returns mockResponse + every { mockResponse.isSuccessful } returns false + + assertThrows(IOException::class.java) { NetUtil.fetch("http://hello") } + } + + @Test + fun `test fetch null response body`() { mockkStatic(NetUtil::class) every { NetUtil.getCall(any()) } returns mockCall every { mockCall.execute() } returns mockResponse + every { mockResponse.isSuccessful } returns true every { mockResponse.body } returns null - every { mockResponseBody.string() } returns "result" - val result = NetUtil.fetch("http://hello") - assert(result.equals("")) + assertThrows(IOException::class.java) { NetUtil.fetch("http://hello") } } @Test diff --git a/core/src/test/kotlin/org/stellar/anchor/util/Sep1HelperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/Sep1HelperTest.kt new file mode 100644 index 0000000000..7ae179320c --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/Sep1HelperTest.kt @@ -0,0 +1,75 @@ +package org.stellar.anchor.util + +import java.io.IOException +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.stellar.anchor.api.exception.InvalidConfigException + +class Sep1HelperTest { + val stellarToml = + """ + ACCOUNTS = [ "GCSGSR6KQQ5BP2FXVPWRL6SWPUSFWLVONLIBJZUKTVQB5FYJFVL6XOXE" ] + VERSION = "0.1.0" + SIGNING_KEY = "GBDYDBJKQBJK4GY4V7FAONSFF2IBJSKNTBYJ65F5KCGBY2BIGPGGLJOH" + NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" + + WEB_AUTH_ENDPOINT = "http://localhost:8080/auth" + KYC_SERVER = "http://localhost:8080/sep12" + TRANSFER_SERVER_SEP0024 = "http://localhost:8080/sep24" + DIRECT_PAYMENT_SERVER = "http://localhost:8080/sep31" + ANCHOR_QUOTE_SERVER = "http://localhost:8080/sep38" + + [[CURRENCIES]] + code = "SRT" + issuer = "GCDNJUBQSX7AJWLJACMJ7I4BC3Z47BQUTMHEICZLE6MU4KQBRYG5JY6B" + status = "test" + is_asset_anchored = false + anchor_asset_type = "other" + desc = "A fake anchored asset to use with this example anchor server." + + [DOCUMENTATION] + ORG_NAME = "Stellar Development Foundation" + ORG_URL = "https://stellar.org" + ORG_DESCRIPTION = "SEP 24 reference server." + ORG_KEYBASE = "stellar.public" + ORG_TWITTER = "StellarOrg" + ORG_GITHUB = "stellar" + """ + .trimIndent() + + private val mockWebServer = MockWebServer() + + @BeforeEach + fun setup() { + mockWebServer.start() + } + + @AfterEach + fun teardown() { + mockWebServer.shutdown() + } + + @Test + fun `test Read Toml with IOException`() { + // Enqueue a response with an HTTP error status code + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + val exception = + assertThrows(IOException::class.java) { + Sep1Helper.readToml(mockWebServer.url("/").toString()) + } + // You may need to adjust the assertion based on the specific behavior of Sep1Helper + assertTrue(exception.message!!.contains("An error occurred while fetching the TOML from")) + } + + @Test + fun `test Parse Invalid Toml String`() { + val invalidTomlString = "key = value" // An invalid TOML string without quotes + val exception = + assertThrows(InvalidConfigException::class.java) { Sep1Helper.parse(invalidTomlString) } + assertEquals("Failed to parse TOML content. Invalid Config.", exception.message) + } +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep1Config.java b/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep1Config.java index 3d40b342bb..70381da1d7 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep1Config.java +++ b/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep1Config.java @@ -6,6 +6,7 @@ import org.springframework.validation.Errors; import org.springframework.validation.ValidationUtils; import org.springframework.validation.Validator; +import org.stellar.anchor.api.exception.InvalidConfigException; import org.stellar.anchor.config.Sep1Config; import org.stellar.anchor.util.NetUtil; import org.stellar.anchor.util.Sep1Helper; @@ -59,12 +60,11 @@ void validateTomlTypeAndValue(PropertySep1Config config, Errors errors) { case STRING: try { Sep1Helper.parse(config.getToml().getValue()); - } catch (IllegalStateException isex) { + } catch (InvalidConfigException e) { errors.rejectValue( "value", "sep1-toml-value-string-invalid", - String.format( - "sep1.toml.value does not contain a valid TOML. %s", isex.getMessage())); + String.format("sep1.toml.value does not contain a valid TOML. %s", e.getMessage())); } break; case URL: diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep1Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep1Controller.java index 7351d2b430..fb20aa5695 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep1Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep1Controller.java @@ -1,5 +1,6 @@ package org.stellar.anchor.platform.controller.sep; +import java.io.IOException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -40,7 +41,7 @@ public RedirectView landingPage() throws SepNotFoundException { @RequestMapping( value = "/.well-known/stellar.toml", method = {RequestMethod.GET, RequestMethod.OPTIONS}) - public ResponseEntity getToml() throws SepNotFoundException { + public ResponseEntity getToml() throws IOException, SepNotFoundException { if (!sep1Config.isEnabled()) { throw new SepNotFoundException("Not Found"); } diff --git a/platform/src/test/kotlin/org/stellar/anchor/Sep1ServiceTest.kt b/platform/src/test/kotlin/org/stellar/anchor/Sep1ServiceTest.kt index 279a8cfc33..d574efa141 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/Sep1ServiceTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/Sep1ServiceTest.kt @@ -1,10 +1,13 @@ package org.stellar.anchor +import java.io.IOException import java.nio.file.Files import java.nio.file.Path import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.stellar.anchor.config.Sep1Config.TomlType.* import org.stellar.anchor.platform.config.PropertySep1Config @@ -13,8 +16,9 @@ import org.stellar.anchor.platform.config.Sep1ConfigTest import org.stellar.anchor.sep1.Sep1Service class Sep1ServiceTest { + lateinit var sep1: Sep1Service - val stellarToml = + private val stellarToml = """ ACCOUNTS = [ "GCSGSR6KQQ5BP2FXVPWRL6SWPUSFWLVONLIBJZUKTVQB5FYJFVL6XOXE" ] VERSION = "0.1.0" @@ -47,7 +51,6 @@ class Sep1ServiceTest { @Test fun `test Sep1Service reading toml from inline string`() { - val config = PropertySep1Config(true, TomlConfig(STRING, stellarToml)) sep1 = Sep1Service(config) assertEquals(sep1.stellarToml, stellarToml) @@ -61,14 +64,62 @@ class Sep1ServiceTest { } @Test - fun `test Sep1Service reading toml from url`() { + fun `getStellarToml fetches data during re-direct`() { val mockServer = MockWebServer() mockServer.start() val mockAnchorUrl = mockServer.url("").toString() mockServer.enqueue(MockResponse().setBody(stellarToml)) - val config = PropertySep1Config(true, TomlConfig(URL, mockAnchorUrl)) sep1 = Sep1Service(config) assertEquals(sep1.stellarToml, stellarToml) } + + // this test is not expected to raise an exception. given the re-direct to a malicious + // endpoint still returns a 200 the exception will be raised/obfuscated + // when the toml is parsed. + @Test + fun `getStellarToml fetches invalid data during malicious re-direct`() { + val mockServer = MockWebServer() + mockServer.start() + val mockAnchorUrl = mockServer.url("").toString() + val metadata = + "{\n" + + " \"ami-id\": \"ami-12345678\",\n" + + " \"instance-id\": \"i-1234567890abcdef\",\n" + + " \"instance-type\": \"t2.micro\"\n" + + " // ... other metadata ...\n" + + "}" + + // Enqueue a response with a 302 status and a Location header to simulate a redirect. + mockServer.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", mockServer.url("/new_location").toString()) + ) + + // Enqueue a response at the redirect location that simulates AWS metadata leak. + mockServer.enqueue(MockResponse().setResponseCode(200).setBody(metadata)) + + val config = PropertySep1Config(true, TomlConfig(URL, mockAnchorUrl)) + val sep1 = Sep1Service(config) + assertEquals(sep1.getStellarToml(), metadata) + mockServer.shutdown() + } + + @Test + fun `getStellarToml throws exception when redirected location results in error`() { + val mockServer = MockWebServer() + mockServer.start() + val mockAnchorUrl = mockServer.url("/new_location").toString() + + // Enqueue a response that provides a server error. + mockServer.enqueue(MockResponse().setResponseCode(500)) + + val config = PropertySep1Config(true, TomlConfig(URL, mockAnchorUrl)) + val exception = assertThrows(IOException::class.java) { sep1 = Sep1Service(config) } + assertTrue( + exception.message?.contains("code=500, message=Server Error, url=http://localhost:") == true + ) + mockServer.shutdown() + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/LogAppenderTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/LogAppenderTest.kt index 824fe940e5..c07703a8ea 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/LogAppenderTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/LogAppenderTest.kt @@ -68,6 +68,10 @@ class LogAppenderTest { "debug" -> Log.debug(wantMessage) "trace" -> Log.trace(wantMessage) } + + // Verify that the append method was called, which ensures that the event was captured + verify { appender.append(any()) } + assertEquals(LogAppenderTest::class.qualifiedName, capturedLogEvent.captured.loggerName) assertEquals(wantLevelName, capturedLogEvent.captured.level.toString()) assertEquals(wantMessage, capturedLogEvent.captured.message.toString()) diff --git a/service-runner/src/main/kotlin/org/stellar/anchor/platform/TestProfileRunner.kt b/service-runner/src/main/kotlin/org/stellar/anchor/platform/TestProfileRunner.kt index 828600ed12..2ede115e7a 100644 --- a/service-runner/src/main/kotlin/org/stellar/anchor/platform/TestProfileRunner.kt +++ b/service-runner/src/main/kotlin/org/stellar/anchor/platform/TestProfileRunner.kt @@ -88,8 +88,9 @@ class TestProfileExecutor(val config: TestConfig) { val envMap = config.env envMap["assets.value"] = getResourceFile(envMap["assets.value"]!!).absolutePath - envMap["sep1.toml.value"] = getResourceFile(envMap["sep1.toml.value"]!!).absolutePath - + if (envMap["sep1.toml.type"] != "url") { + envMap["sep1.toml.value"] = getResourceFile(envMap["sep1.toml.value"]!!).absolutePath + } // Start servers val jobs = mutableListOf() val scope = CoroutineScope(Dispatchers.Default)