diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a20aa95799..2ae769f0e56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - master - beta + - private-preview - sdk-release/** - feature/** tags: @@ -125,6 +126,9 @@ jobs: echo "JAVA_TEST_HOME=$JAVA_TEST_HOME" - uses: stripe/openapi/actions/stripe-mock@master + with: + # Used to determine if stripe-mock runs in beta mode + base: ${{ github.base_ref == 'private-preview' && 'beta' || github.ref_name == 'private-preview' && 'beta' || contains(github.ref_name, '-alpha.') && 'beta' || github.base_ref || github.ref_name }} - name: Run test suite run: just test diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4bbbb9a2f..f44737e45f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ This release changes the pinned API version to 2026-08-26.preview. * Add support for event notification `V2SignalsAccountEvaluationCompleteEvent` with related object `v2.signals.AccountEvaluation` * [#2267](https://github.com/stripe/stripe-java/pull/2267) Add non-verified methods to managed handlers +## 33.4.1 - 2026-09-01 +* [#2284](https://github.com/stripe/stripe-java/pull/2284) Harden API requestor code against malicious URLs + ## 33.4.0 - 2026-08-26 This release changes the pinned API version to 2026-08-26.dahlia. diff --git a/CODEGEN_VERSION b/CODEGEN_VERSION index 308945cbf59..635c5cd381b 100644 --- a/CODEGEN_VERSION +++ b/CODEGEN_VERSION @@ -1 +1 @@ -baff58c9d515cdd5f5c3231d101989d588788c6f \ No newline at end of file +e2d3e8255ba431b6d05e4a423941a157e947e763 \ No newline at end of file diff --git a/OPENAPI_VERSION b/OPENAPI_VERSION index 28d67d2bfca..ef8e197a3ce 100644 --- a/OPENAPI_VERSION +++ b/OPENAPI_VERSION @@ -1 +1 @@ -v2442 \ No newline at end of file +v2459 \ No newline at end of file diff --git a/src/main/java/com/stripe/model/v2/core/EventNotification.java b/src/main/java/com/stripe/model/v2/core/EventNotification.java index 96f637d4544..064e00ac006 100644 --- a/src/main/java/com/stripe/model/v2/core/EventNotification.java +++ b/src/main/java/com/stripe/model/v2/core/EventNotification.java @@ -144,9 +144,14 @@ private RawRequestOptions getRequestOptions() { /* retrieves the full payload for an event. Protected because individual push classes use it, but type it correctly */ protected Event fetchEvent() throws StripeException { + // `id` comes from the notification body, so encode it the way the generated + // services do -- otherwise it can inject extra path or query segments. StripeResponse response = client.rawRequest( - RequestMethod.GET, String.format("/v2/core/events/%s", id), null, getRequestOptions()); + RequestMethod.GET, + String.format("/v2/core/events/%s", ApiResource.urlEncodeId(id)), + null, + getRequestOptions()); return (Event) client.deserialize(response.body(), ApiMode.V2); } diff --git a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java index b04b29f1fcb..9b8e3fdf19b 100644 --- a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java +++ b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java @@ -475,6 +475,27 @@ public void validateRequestOptions(RequestOptions options) { } } + /** + * Asserts that a request path is origin-relative: that it begins with a single {@code "/"}. + * + *

The absolute URL is built by concatenating a base URL onto this path, and no base URL ends + * in a slash. A path like {@code "@evil.example/v1/x"} or {@code ".evil.example/v1/x"} would + * modify the resulting host and direct the request (including the API key) to a non-Stripe host. + * + *

Because some relative urls arrive from potentially untrusted sources (like webhook bodies), + * we have to be a little defensive. + * + *

So, we require that a path starts with a leading slash. Deliberately not using {@link + * java.net.URI} to parse -- it enforces RFC 2396 strictly and would reject paths containing + * characters that callers have always been able to send. + */ + static void validatePath(String path) { + if (path == null || !path.startsWith("/") || path.startsWith("//")) { + throw new IllegalArgumentException( + "Request path must begin with a single \"/\", got: " + path); + } + } + private String fullUrl(BaseApiRequest apiRequest) { BaseAddress baseAddress = apiRequest.getBaseAddress(); RequestOptions options = apiRequest.getOptions(); @@ -499,6 +520,7 @@ private String fullUrl(BaseApiRequest apiRequest) { if (options != null && options.getBaseUrl() != null) { baseUrl = options.getBaseUrl(); } + validatePath(relativeUrl); return String.format("%s%s", baseUrl, relativeUrl); } } diff --git a/src/test/java/com/stripe/net/DiscriminatedUnionSerializationTest.java b/src/test/java/com/stripe/net/DiscriminatedUnionSerializationTest.java new file mode 100644 index 00000000000..82417d7448e --- /dev/null +++ b/src/test/java/com/stripe/net/DiscriminatedUnionSerializationTest.java @@ -0,0 +1,351 @@ +package com.stripe.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.stripe.model.StripeObject; +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class DiscriminatedUnionSerializationTest { + private final ApiRequestParamsConverter converter = new ApiRequestParamsConverter(); + + private final Gson testGson = + new GsonBuilder() + .registerTypeAdapterFactory(new TestColorTypeAdapterFactory()) + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + + // --------------------------------------------------------------------------- + // Request-side fixtures — standalone union + // The parent params hold an Object-typed field that can hold any variant. + // --------------------------------------------------------------------------- + + @SuppressWarnings("UnusedVariable") + private static class TestCreateParams extends ApiRequestParams { + @SerializedName("color") + Object color; + + @SerializedName("name") + String name; + } + + @SuppressWarnings("UnusedVariable") + private static class TestRgbColorParams extends ApiRequestParams { + @SerializedName("model") + String model = "rgb"; + + @SerializedName("r") + Long r; + + @SerializedName("g") + Long g; + + @SerializedName("b") + Long b; + } + + @SuppressWarnings("UnusedVariable") + private static class TestHsvColorParams extends ApiRequestParams { + @SerializedName("model") + String model = "hsv"; + + @SerializedName("h") + Long h; + + @SerializedName("s") + Long s; + + @SerializedName("v") + Long v; + } + + // --------------------------------------------------------------------------- + // Request-side fixtures — inline union + // The parent params hold the discriminator and each variant's fields directly. + // --------------------------------------------------------------------------- + + @SuppressWarnings("UnusedVariable") + private static class TestInlineParams extends ApiRequestParams { + @SerializedName("type") + String type; + + @SerializedName("card") + TestCardParams card; + + @SerializedName("bank") + TestBankParams bank; + } + + @SuppressWarnings("UnusedVariable") + private static class TestCardParams extends ApiRequestParams { + @SerializedName("number") + String number; + + @SerializedName("exp_month") + Long expMonth; + } + + @SuppressWarnings("UnusedVariable") + private static class TestBankParams extends ApiRequestParams { + @SerializedName("routing_number") + String routingNumber; + + @SerializedName("account_number") + String accountNumber; + } + + // --------------------------------------------------------------------------- + // Response-side fixtures + // --------------------------------------------------------------------------- + + private static class TestColorEntity extends StripeObject { + @SerializedName("model") + String model; + } + + private static class TestRgbColorEntity extends TestColorEntity { + @SerializedName("r") + Long r; + + @SerializedName("g") + Long g; + + @SerializedName("b") + Long b; + } + + private static class TestHsvColorEntity extends TestColorEntity { + @SerializedName("h") + Long h; + + @SerializedName("s") + Long s; + + @SerializedName("v") + Long v; + } + + private static class TestColorTypeAdapterFactory implements TypeAdapterFactory { + @Override + @SuppressWarnings("unchecked") + public TypeAdapter create(Gson gson, TypeToken type) { + if (!TestColorEntity.class.isAssignableFrom(type.getRawType())) { + return null; + } + return (TypeAdapter) + new TypeAdapter() { + @Override + @SuppressWarnings("unchecked") + public void write(JsonWriter out, TestColorEntity value) throws IOException { + ((TypeAdapter) gson.getAdapter(value.getClass())).write(out, value); + } + + @Override + public TestColorEntity read(JsonReader in) throws IOException { + JsonObject obj = JsonParser.parseReader(in).getAsJsonObject(); + String model = obj.has("model") ? obj.get("model").getAsString() : null; + if ("rgb".equals(model)) { + return gson.getDelegateAdapter( + TestColorTypeAdapterFactory.this, TypeToken.get(TestRgbColorEntity.class)) + .fromJsonTree(obj); + } else if ("hsv".equals(model)) { + return gson.getDelegateAdapter( + TestColorTypeAdapterFactory.this, TypeToken.get(TestHsvColorEntity.class)) + .fromJsonTree(obj); + } + return gson.getDelegateAdapter( + TestColorTypeAdapterFactory.this, TypeToken.get(TestColorEntity.class)) + .fromJsonTree(obj); + } + }; + } + } + + private static class TestColorContainer extends StripeObject { + @SerializedName("color") + TestColorEntity color; + + @SerializedName("name") + String name; + } + + private static class TestPaymentEntity extends StripeObject { + @SerializedName("type") + String type; + + @SerializedName("card") + TestCardEntity card; + + @SerializedName("bank") + TestBankEntity bank; + } + + private static class TestCardEntity extends StripeObject { + @SerializedName("number") + String number; + + @SerializedName("exp_month") + Long expMonth; + } + + private static class TestBankEntity extends StripeObject { + @SerializedName("routing_number") + String routingNumber; + + @SerializedName("account_number") + String accountNumber; + } + + // --------------------------------------------------------------------------- + // Tests — request side (params → map) + // --------------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + public void testStandaloneUnion_RgbVariant_Serialization() { + TestRgbColorParams rgb = new TestRgbColorParams(); + rgb.r = 255L; + rgb.g = 128L; + rgb.b = 0L; + + TestCreateParams params = new TestCreateParams(); + params.color = rgb; + params.name = "sunset"; + + Map map = converter.convert(params); + assertEquals("sunset", map.get("name")); + + Map colorMap = (Map) map.get("color"); + assertEquals("rgb", colorMap.get("model")); + assertEquals(255L, colorMap.get("r")); + assertEquals(128L, colorMap.get("g")); + assertEquals(0L, colorMap.get("b")); + } + + @Test + @SuppressWarnings("unchecked") + public void testStandaloneUnion_HsvVariant_Serialization() { + TestHsvColorParams hsv = new TestHsvColorParams(); + hsv.h = 30L; + hsv.s = 100L; + hsv.v = 100L; + + TestCreateParams params = new TestCreateParams(); + params.color = hsv; + params.name = "orange"; + + Map map = converter.convert(params); + assertEquals("orange", map.get("name")); + + Map colorMap = (Map) map.get("color"); + assertEquals("hsv", colorMap.get("model")); + assertEquals(30L, colorMap.get("h")); + assertEquals(100L, colorMap.get("s")); + assertEquals(100L, colorMap.get("v")); + } + + @Test + @SuppressWarnings("unchecked") + public void testInlineUnion_CardVariant_Serialization() { + TestCardParams card = new TestCardParams(); + card.number = "4242424242424242"; + card.expMonth = 12L; + + TestInlineParams params = new TestInlineParams(); + params.type = "card"; + params.card = card; + + Map map = converter.convert(params); + assertEquals("card", map.get("type")); + + Map cardMap = (Map) map.get("card"); + assertEquals("4242424242424242", cardMap.get("number")); + assertEquals(12L, cardMap.get("exp_month")); + + // Non-selected variant is not present in serialized output. + assertEquals(null, map.get("bank")); + } + + @Test + @SuppressWarnings("unchecked") + public void testInlineUnion_BankVariant_Serialization() { + TestBankParams bank = new TestBankParams(); + bank.routingNumber = "110000000"; + bank.accountNumber = "000123456789"; + + TestInlineParams params = new TestInlineParams(); + params.type = "bank"; + params.bank = bank; + + Map map = converter.convert(params); + assertEquals("bank", map.get("type")); + + Map bankMap = (Map) map.get("bank"); + assertEquals("110000000", bankMap.get("routing_number")); + assertEquals("000123456789", bankMap.get("account_number")); + } + + // --------------------------------------------------------------------------- + // Tests — response side (JSON → object) + // --------------------------------------------------------------------------- + + @Test + public void testStandaloneUnion_RgbVariant_Deserialization() { + String json = + "{\"color\": {\"model\": \"rgb\", \"r\": 255, \"g\": 128, \"b\": 0}, \"name\": \"sunset\"}"; + + TestColorContainer container = testGson.fromJson(json, TestColorContainer.class); + + assertEquals("sunset", container.name); + assertTrue(container.color instanceof TestRgbColorEntity); + TestRgbColorEntity rgb = (TestRgbColorEntity) container.color; + assertEquals("rgb", rgb.model); + assertEquals(Long.valueOf(255L), rgb.r); + assertEquals(Long.valueOf(128L), rgb.g); + assertEquals(Long.valueOf(0L), rgb.b); + } + + @Test + public void testStandaloneUnion_HsvVariant_Deserialization() { + String json = + "{\"color\": {\"model\": \"hsv\", \"h\": 30, \"s\": 100, \"v\": 50}, \"name\": \"orange\"}"; + + TestColorContainer container = testGson.fromJson(json, TestColorContainer.class); + + assertEquals("orange", container.name); + assertTrue(container.color instanceof TestHsvColorEntity); + TestHsvColorEntity hsv = (TestHsvColorEntity) container.color; + assertEquals("hsv", hsv.model); + assertEquals(Long.valueOf(30L), hsv.h); + assertEquals(Long.valueOf(100L), hsv.s); + assertEquals(Long.valueOf(50L), hsv.v); + } + + @Test + public void testInlineUnion_CardVariant_Deserialization() { + String json = + "{\"type\": \"card\", \"card\": {\"number\": \"4242424242424242\", \"exp_month\": 12}}"; + + TestPaymentEntity entity = ApiResource.GSON.fromJson(json, TestPaymentEntity.class); + + assertEquals("card", entity.type); + assertEquals("4242424242424242", entity.card.number); + assertEquals(Long.valueOf(12L), entity.card.expMonth); + + // Non-selected variant remains null. + assertEquals(null, entity.bank); + } +} diff --git a/src/test/java/com/stripe/net/OriginRelativePathTest.java b/src/test/java/com/stripe/net/OriginRelativePathTest.java new file mode 100644 index 00000000000..9e4f8c5eac4 --- /dev/null +++ b/src/test/java/com/stripe/net/OriginRelativePathTest.java @@ -0,0 +1,65 @@ +package com.stripe.net; + +import static org.junit.jupiter.api.Assertions.*; + +import com.stripe.BaseStripeTest; +import org.junit.jupiter.api.Test; + +public class OriginRelativePathTest extends BaseStripeTest { + + private static final String[] ORIGIN_RELATIVE_PATHS = { + "/v1/customers/cus_123", + "/v1/customers", + "/v2/core/accounts?page=page_123&limit=2", + // '@' is legal inside a path or query string -- it only opens an authority + // when it precedes the first '/'. + "/v1/customers?email=user%40example.com", + "/v1/invoices/in_123@456", + // A backslash does not open an authority: the '/' already closed it. + "/v1/\\evil.example", + }; + + private static final String[] HOSTILE_PATHS = { + // Concatenated onto a base URL with no trailing slash, each of these moves + // the request's authority off api.stripe.com. + "@evil.example/v1/leak", + ":pw@evil.example/v1/leak", + ":80@evil.example/v1/leak", + // Extends the host into an attacker-owned subdomain + // (api.stripe.com.evil.example), which has a valid certificate. + ".evil.example/v1/leak", + "-evil.example/v1/leak", + "https://evil.example/v1/leak", + "//evil.example/v1/leak", + "", + "v1/customers", + null, + }; + + @Test + public void testAcceptsOriginRelativePaths() { + for (String path : ORIGIN_RELATIVE_PATHS) { + assertDoesNotThrow( + () -> LiveStripeResponseGetter.validatePath(path), "expected to accept: " + path); + } + } + + @Test + public void testRejectsHostilePaths() { + for (String path : HOSTILE_PATHS) { + assertThrows( + IllegalArgumentException.class, + () -> LiveStripeResponseGetter.validatePath(path), + "expected to reject: " + path); + } + } + + @Test + public void testRejectionMessageNamesThePath() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> LiveStripeResponseGetter.validatePath("@evil.example/v1/leak")); + assertTrue(e.getMessage().contains("@evil.example/v1/leak")); + } +}