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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
branches:
- master
- beta
- private-preview
- sdk-release/**
- feature/**
tags:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ sends by default. If you are overriding `stripe.api_version` / `stripe_version`
[webhook endpoint](https://stripe.com/docs/webhooks#api-versions) tied to an older version,
be aware that the data you see at runtime may not match the types.

### Open and Closed Enums

Many of Stripe API enums are open, meaning Stripe may add new values even on older API versions.
To reflect this, open enum fields are typed as `Union[Literal[...], str]` rather than a plain `Literal[...]`.
This ensures the field has the correct type for both values known at SDK release time and other values that may be added later.

A small number of enums are closed, meaning Stripe guarantees no new values will be added without an API version change.

Refer to the [API Reference](https://docs.stripe.com) for the latest set of allowed values.


### Public Preview SDKs

Stripe has features in the [public preview phase](https://docs.stripe.com/release-phases) that can be accessed via versions of this package that have the `bX` suffix like `12.2.0b2`.
Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ typecheck: install-test-deps install-dev-deps

# ⭐ format all code
format: install-dev-deps
ruff format . --quiet
ruff format . > /dev/null

# verify formatting, but don't modify files
format-check: install-dev-deps
ruff format . --check --quiet
ruff format . --check > /dev/null

# remove venv & build artifacts
clean:
Expand Down
2 changes: 2 additions & 0 deletions stripe/_api_requestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
log_debug,
log_info,
dashboard_link,
validate_path,
_convert_to_stripe_object,
get_api_mode,
)
Expand Down Expand Up @@ -654,6 +655,7 @@ def _args_for_request_with_retries(
"questions."
)

validate_path(url)
abs_url = "%s%s" % (
self._options.base_addresses.get(base_address),
url,
Expand Down
30 changes: 23 additions & 7 deletions stripe/_multipart_data_generator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import random
import io
import secrets

from stripe._encode import _api_encode

# Number of random bytes used to build the boundary, matching stripe-ruby's
# `SecureRandom.hex(30)` and Go's mime/multipart. This must come from a CSPRNG:
# multipart/form-data is only safe if the delimiter cannot be guessed by anyone
# able to influence the content, since a value containing the delimiter gets
# parsed as additional parts.
BOUNDARY_BYTES = 30


def _escape_header_value(value: str) -> str:
"""Make a value safe to interpolate into a part header.

An unescaped quote would end the quoted-string early, and CR/LF would
introduce additional header lines or parts.
"""
return value.replace('"', "%22").replace("\r", " ").replace("\n", " ")


class MultipartDataGenerator(object):
data: io.BytesIO
line_break: str
boundary: int
boundary: str
chunk_size: int

def __init__(self, chunk_size: int = 1028):
Expand Down Expand Up @@ -36,9 +52,9 @@ def add_params(self, params):
filename = str(value.name)

self._write('Content-Disposition: form-data; name="')
self._write(key)
self._write(_escape_header_value(key))
self._write('"; filename="')
self._write(filename)
self._write(_escape_header_value(filename))
self._write('"')
self._write(self.line_break)
self._write("Content-Type: application/octet-stream")
Expand All @@ -48,7 +64,7 @@ def add_params(self, params):
self._write_file(value)
else:
self._write('Content-Disposition: form-data; name="')
self._write(key)
self._write(_escape_header_value(key))
self._write('"')
self._write(self.line_break)
self._write(self.line_break)
Expand Down Expand Up @@ -83,5 +99,5 @@ def _write_file(self, f):
break
self._write(file_contents)

def _initialize_boundary(self):
return random.randint(0, 2**63)
def _initialize_boundary(self) -> str:
return secrets.token_hex(BOUNDARY_BYTES)
34 changes: 33 additions & 1 deletion stripe/_stripe_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,9 @@ def _refresh_from(
for k, v in values.items():
# Apply field encoding coercion (e.g. int64_string: str → int)
v = self._coerce_field_value(k, v)
inner_class = self._get_inner_class_type(k)
inner_class = self._get_union_variant_class(
k, v
) or self._get_inner_class_type(k)
is_dict = self._get_inner_class_is_beneath_dict(k)
if is_dict:
obj = {
Expand Down Expand Up @@ -682,11 +684,41 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> "StripeObject":
_inner_class_dicts: ClassVar[List[str]] = []
_field_encodings: ClassVar[Dict[str, str]] = {}

# Maps a discriminated-union field to (discriminator, {value: class}). Generated
# subclasses override this; every other object keeps the empty default so the
# lookup in _update_attributes stays cheap.
_inner_class_union_variant_types: ClassVar[
Dict[str, Tuple[str, Dict[str, Type["StripeObject"]]]]
] = {}

def _get_inner_class_type(
self, field_name: str
) -> Optional[Type["StripeObject"]]:
return self._inner_class_types.get(field_name)

def _get_union_variant_class(
self, field_name: str, value: Any
) -> Optional[Type["StripeObject"]]:
"""
Returns the variant class that a discriminated union field's value should
become, based on the discriminator carried in the value itself.

Returns None rather than raising when the discriminator is absent, is not a
string, or names a variant this version of the SDK does not know about. The
caller then converts without a class, so a variant the API adds after this
release still deserializes instead of blowing up.
"""
union = self._inner_class_union_variant_types.get(field_name)
if union is None or not isinstance(value, dict):
return None

discriminator, variants = union
discriminator_value = cast(Dict[str, Any], value).get(discriminator)
if not isinstance(discriminator_value, str):
return None

return variants.get(discriminator_value)

def _get_inner_class_is_beneath_dict(self, field_name: str):
return field_name in self._inner_class_dicts

Expand Down
39 changes: 36 additions & 3 deletions stripe/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import re

from stripe._api_mode import ApiMode
from urllib.parse import quote_plus

from urllib.parse import quote_plus, urlsplit

from typing_extensions import Type, TYPE_CHECKING
from typing import (
Expand Down Expand Up @@ -259,8 +260,12 @@ def _convert_to_stripe_object(
klass = get_object_class(api_mode, klass_name)
# TODO: this is a horrible hack. The API needs
# to return something for `object` here.

elif "data" in resp and "next_page_url" in resp:
#
# Gated on V2: this runs recursively over every nested value, so without
# the mode check any nested map in a v1 payload carrying `data` and
# `next_page_url` becomes an auto-paginating v2 collection. A malicious webhook
# could potentially choose the host of a subsequent authenticated request.
elif api_mode == "V2" and "data" in resp and "next_page_url" in resp:
klass = stripe.v2.ListObject
elif klass_ is not None:
klass = klass_
Expand Down Expand Up @@ -345,6 +350,34 @@ def sanitize_id(id):
return quotedId


def validate_path(path: str) -> None:
"""
Assert that a request path is origin-relative: that it begins with a single
"/" and carries no scheme, authority or userinfo.

The absolute URL is built by concatenating a base address onto this path, and
no base address ends in a slash. A path like "@evil.example/v1/x" or
".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 and that urlsplit
finds no scheme or authority in it.
"""
if not path.startswith("/") or path.startswith("//"):
raise ValueError(
f'Request path must be a string beginning with a single "/", got: {path!r}'
)

parts = urlsplit(path)
if parts.scheme or parts.netloc:
raise ValueError(
f"Request path may not contain a scheme or authority, got: {path!r}"
)


def get_api_mode(url: str) -> ApiMode:
if url.startswith("/v2"):
return "V2"
Expand Down
6 changes: 3 additions & 3 deletions stripe/v2/core/_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing_extensions import Literal, TYPE_CHECKING

from stripe._stripe_object import StripeObject, UntypedStripeObject
from stripe._util import get_api_mode
from stripe._util import get_api_mode, sanitize_id
from stripe._stripe_context import StripeContext
from stripe._webhook import WebhookPayload

Expand Down Expand Up @@ -221,7 +221,7 @@ def __repr__(self) -> str:
def fetch_event(self) -> Event:
response = self._client.raw_request(
"get",
f"/v2/core/events/{self.id}",
f"/v2/core/events/{sanitize_id(self.id)}",
stripe_context=self.context,
headers={"Stripe-Request-Trigger": f"event={self.id}"},
usage=["pushed_event_pull"],
Expand All @@ -231,7 +231,7 @@ def fetch_event(self) -> Event:
async def fetch_event_async(self) -> Event:
response = await self._client.raw_request_async(
"get",
f"/v2/core/events/{self.id}",
f"/v2/core/events/{sanitize_id(self.id)}",
stripe_context=self.context,
headers={"Stripe-Request-Trigger": f"event={self.id}"},
usage=["pushed_event_pull", "pushed_event_pull_async"],
Expand Down
12 changes: 9 additions & 3 deletions tests/api_resources/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ def test_is_retrievable(self, http_client_mock):
)
assert isinstance(resource, stripe.File)

def test_is_creatable(self, setup_upload_api_base, http_client_mock):
MultipartDataGenerator._initialize_boundary = lambda self: 1234567890
def test_is_creatable(
self, setup_upload_api_base, http_client_mock, monkeypatch
):
monkeypatch.setattr(
MultipartDataGenerator,
"_initialize_boundary",
lambda self: "abc123",
)
test_file = tempfile.TemporaryFile()
resource = stripe.File.create(
purpose="dispute_evidence",
Expand All @@ -44,7 +50,7 @@ def test_is_creatable(self, setup_upload_api_base, http_client_mock):
"post",
path="/v1/files",
api_base=stripe.upload_api_base,
content_type="multipart/form-data; boundary=1234567890",
content_type="multipart/form-data; boundary=abc123",
)
assert isinstance(resource, stripe.File)

Expand Down
12 changes: 9 additions & 3 deletions tests/api_resources/test_file_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ def test_is_retrievable(self, http_client_mock):
)
assert isinstance(resource, File)

def test_is_creatable(self, setup_upload_api_base, http_client_mock):
MultipartDataGenerator._initialize_boundary = lambda self: 1234567890
def test_is_creatable(
self, setup_upload_api_base, http_client_mock, monkeypatch
):
monkeypatch.setattr(
MultipartDataGenerator,
"_initialize_boundary",
lambda self: "abc123",
)
test_file = tempfile.TemporaryFile()
resource = File.create(
purpose="dispute_evidence",
Expand All @@ -45,7 +51,7 @@ def test_is_creatable(self, setup_upload_api_base, http_client_mock):
"post",
api_base=stripe.upload_api_base,
path="/v1/files",
content_type="multipart/form-data; boundary=1234567890",
content_type="multipart/form-data; boundary=abc123",
)
assert isinstance(resource, File)

Expand Down
9 changes: 7 additions & 2 deletions tests/services/test_file_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ def test_is_creatable(
self,
file_stripe_mock_stripe_client,
http_client_mock,
monkeypatch,
):
MultipartDataGenerator._initialize_boundary = lambda self: 1234567890
monkeypatch.setattr(
MultipartDataGenerator,
"_initialize_boundary",
lambda self: "abc123",
)
test_file = tempfile.TemporaryFile()

# We create a new client here instead of re-using the stripe_mock_stripe_client fixture
Expand All @@ -46,6 +51,6 @@ def test_is_creatable(
"post",
api_base=stripe.upload_api_base,
path="/v1/files",
content_type="multipart/form-data; boundary=1234567890",
content_type="multipart/form-data; boundary=abc123",
)
assert isinstance(resource, File)
Loading