From 9651ffee1ef3d508c0c1f5a0537a7a3947bf98c3 Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Fri, 28 Aug 2026 09:47:36 -0700 Subject: [PATCH 1/4] Add discriminated union serialization tests (#2260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add discriminated union serialization tests Tests both request-side (params → map) and response-side (JSON → object) serialization for discriminated unions, covering standalone and inline variants. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude * Add null assertions for non-selected DU variants Assert that when one variant is selected, the other variant fields/structs remain null in both serialization and deserialization tests. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude * Fix response standalone DU test to use proper class hierarchy Restructure the response-side test fixtures from a flat TestColorEntity class (holding all variant fields) to a proper class hierarchy: TestColorEntity (base) -> TestRgbColorEntity / TestHsvColorEntity. Add a TestColorTypeAdapterFactory that dispatches deserialization to the correct subclass based on the `model` discriminator field, mirroring how the codegen will emit TypeAdapterFactory-based dispatch for real DUs. Update assertions to use instanceof checks and typed casts rather than checking for null fields on a flat class. Add a second test covering the HSV variant path. Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude * fixed formatting --------- Co-authored-by: Claude Opus 4.6 --- .../DiscriminatedUnionSerializationTest.java | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 src/test/java/com/stripe/net/DiscriminatedUnionSerializationTest.java 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); + } +} From ac39a603a29d1fb6f90c68ed339a09d6bddfc9b9 Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Fri, 28 Aug 2026 10:54:40 -0700 Subject: [PATCH 2/4] Carry private-preview's CI workflow hunks on master (#2283) private-preview's ci.yml differs from master's by exactly two additive hunks: `private-preview` in `on.push.branches`, and a `base:` input on the stripe-mock step mapping every private-preview context onto the beta spec. Because those hunks live only on private-preview, every merge of master into private-preview yields a ci.yml matching neither parent. GitHub refuses a push from a GitHub App lacking `workflows` permission when it introduces a workflow blob that does not already exist in the repository, so the codegen job's push is rejected and a human has to perform the merge by hand. Holding the hunks on master too means both sides of the merge carry the same change, the merge result is byte-identical to master's blob, and the App only ever carries an already-committed file forward. Both hunks are no-ops on master: - For a push event the workflow file comes from the pushed ref, so master's copy listing private-preview cannot affect pushes to master or beta. - The base: expression falls through to `github.base_ref || github.ref_name` for every master-side ref, which is character-identical to the default declared by stripe/openapi/actions/stripe-mock. Committed-By-Agent: claude --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) 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 From e4f0ec3d1220a542d45646c873331a7ec6ddcc70 Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:27:21 -0700 Subject: [PATCH 3/4] Harden API requestor code against malicious URLs (#2284) * validate that incoming urls don't redirect requests * shorten comments --- .../model/v2/core/EventNotification.java | 7 +- .../stripe/net/LiveStripeResponseGetter.java | 22 +++++++ .../stripe/net/OriginRelativePathTest.java | 65 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/stripe/net/OriginRelativePathTest.java 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 0544219a18a..49036e5a3ec 100644 --- a/src/main/java/com/stripe/model/v2/core/EventNotification.java +++ b/src/main/java/com/stripe/model/v2/core/EventNotification.java @@ -143,9 +143,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 dea339c4b13..185a5a3e747 100644 --- a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java +++ b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java @@ -476,6 +476,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(); @@ -500,6 +521,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/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")); + } +} From 6eb9134f6ce13f58f68db551b461e4036493ece9 Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Tue, 1 Sep 2026 11:40:29 -0700 Subject: [PATCH 4/4] Bump version to 33.4.1 --- CHANGELOG.md | 3 +++ README.md | 10 +++++----- VERSION | 2 +- gradle.properties | 2 +- src/main/java/com/stripe/Stripe.java | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0614d3a6f..0f6ab4f9c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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/README.md b/README.md index 7d1f60c21ec..e6fe4b1d547 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stripe Java client library -[![Maven Central](https://img.shields.io/badge/maven--central-v33.4.0-blue)](https://mvnrepository.com/artifact/com.stripe/stripe-java) +[![Maven Central](https://img.shields.io/badge/maven--central-v33.4.1-blue)](https://mvnrepository.com/artifact/com.stripe/stripe-java) [![JavaDoc](http://img.shields.io/badge/javadoc-reference-blue.svg)](https://stripe.dev/stripe-java) [![Build Status](https://github.com/stripe/stripe-java/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/stripe/stripe-java/actions?query=branch%3Amaster) @@ -26,7 +26,7 @@ We support LTS versions of the JDK. Currently, that's Java versions: Add this dependency to your project's build file: ```groovy -implementation "com.stripe:stripe-java:33.4.0" +implementation "com.stripe:stripe-java:33.4.1" ``` ### Maven users @@ -37,7 +37,7 @@ Add this dependency to your project's POM: com.stripe stripe-java - 33.4.0 + 33.4.1 ``` @@ -46,8 +46,8 @@ Add this dependency to your project's POM: If you are not using Gradle or Maven, you will need to manually install the following JARs: 1. The Stripe JAR: - - Download the latest release version from [Maven Central](https://repo1.maven.org/maven2/com/stripe/stripe-java/33.4.0/stripe-java-33.4.0.jar) - - Current release version: 33.4.0 + - Download the latest release version from [Maven Central](https://repo1.maven.org/maven2/com/stripe/stripe-java/33.4.1/stripe-java-33.4.1.jar) + - Current release version: 33.4.1 2. Google Gson: - The Stripe JAR builds and tests with Gson version 2.10.1 diff --git a/VERSION b/VERSION index 5acd8981cd0..e5a9ad36417 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -33.4.0 +33.4.1 diff --git a/gradle.properties b/gradle.properties index de8ff3ad5f8..b01de233282 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=com.stripe -VERSION_NAME=33.4.0 +VERSION_NAME=33.4.1 POM_URL=https://github.com/stripe/stripe-java POM_SCM_URL=git@github.com:stripe/stripe-java.git diff --git a/src/main/java/com/stripe/Stripe.java b/src/main/java/com/stripe/Stripe.java index 05068457c0a..02c358969a3 100644 --- a/src/main/java/com/stripe/Stripe.java +++ b/src/main/java/com/stripe/Stripe.java @@ -20,7 +20,7 @@ public abstract class Stripe { public static final String LIVE_API_BASE = "https://api.stripe.com"; public static final String UPLOAD_API_BASE = "https://files.stripe.com"; public static final String METER_EVENTS_API_BASE = "https://meter-events.stripe.com"; - public static final String VERSION = "33.4.0"; + public static final String VERSION = "33.4.1"; public static volatile String apiKey; public static volatile String clientId;