diff --git a/server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py b/server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py new file mode 100644 index 0000000000..c38f25c299 --- /dev/null +++ b/server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py @@ -0,0 +1,55 @@ +"""Unescape checkout_id placeholder in success URLs + +Revision ID: b797366de1fb +Revises: bf0f120ca9a9 +Create Date: 2025-09-15 21:12:39.367225 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "b797366de1fb" +down_revision = "bf0f120ca9a9" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + +ENCODED_PLACEHOLDER = "%7BCHECKOUT_ID%7D" +DECODED_PLACEHOLDER = "{CHECKOUT_ID}" + + +def upgrade() -> None: + op.execute( + f""" + UPDATE checkout_links + SET success_url = REPLACE(success_url, '{ENCODED_PLACEHOLDER}', '{DECODED_PLACEHOLDER}') + WHERE success_url LIKE '%{ENCODED_PLACEHOLDER}%' + """ + ) + op.execute( + f""" + UPDATE checkouts + SET success_url = REPLACE(success_url, '{ENCODED_PLACEHOLDER}', '{DECODED_PLACEHOLDER}') + WHERE success_url LIKE '%{ENCODED_PLACEHOLDER}%' + """ + ) + + +def downgrade() -> None: + op.execute( + f""" + UPDATE checkout_links + SET success_url = REPLACE(success_url, '{DECODED_PLACEHOLDER}', '{ENCODED_PLACEHOLDER}') + WHERE success_url LIKE '%{DECODED_PLACEHOLDER}%' + """ + ) + op.execute( + f""" + UPDATE checkouts + SET success_url = REPLACE(success_url, '{DECODED_PLACEHOLDER}', '{ENCODED_PLACEHOLDER}') + WHERE success_url LIKE '%{DECODED_PLACEHOLDER}%' + """ + ) diff --git a/server/polar/checkout/schemas.py b/server/polar/checkout/schemas.py index d3ebf6ec11..53ff72aaee 100644 --- a/server/polar/checkout/schemas.py +++ b/server/polar/checkout/schemas.py @@ -7,10 +7,10 @@ AliasChoices, Discriminator, Field, - HttpUrl, IPvAnyAddress, Tag, computed_field, + field_validator, ) from pydantic.json_schema import SkipJsonSchema @@ -46,6 +46,7 @@ TrialConfigurationOutputMixin, TrialInterval, ) +from polar.kit.validators import validate_http_url from polar.models.checkout import ( CheckoutBillingAddressFields, CheckoutCustomerBillingAddressFields, @@ -97,7 +98,7 @@ Address, Field(description="Billing address of the customer.") ] SuccessURL = Annotated[ - HttpUrl | None, + str | None, Field( description=( "URL where the customer will be redirected after a successful payment." @@ -204,6 +205,11 @@ class CheckoutCreateBase( success_url: SuccessURL = None embed_origin: EmbedOrigin = None + @field_validator("success_url") + @classmethod + def validate_is_http_url(cls, url: str | None) -> str | None: + return validate_http_url(url) + class CheckoutPriceCreate(CheckoutCreateBase): """ @@ -325,6 +331,11 @@ class CheckoutUpdate( success_url: SuccessURL = None embed_origin: EmbedOrigin = None + @field_validator("success_url") + @classmethod + def validate_is_http_url(cls, url: str | None) -> str | None: + return validate_http_url(url) + class CheckoutUpdatePublic(CheckoutUpdateBase): """Update an existing checkout session using the client secret.""" diff --git a/server/polar/checkout_link/schemas.py b/server/polar/checkout_link/schemas.py index 266a19a615..8124df0fb8 100644 --- a/server/polar/checkout_link/schemas.py +++ b/server/polar/checkout_link/schemas.py @@ -1,6 +1,12 @@ from typing import Annotated, Literal -from pydantic import UUID4, AliasPath, Field, HttpUrl, computed_field +from pydantic import ( + UUID4, + AliasPath, + Field, + computed_field, + field_validator, +) from pydantic.json_schema import SkipJsonSchema from polar.config import settings @@ -18,6 +24,7 @@ TimestampedSchema, ) from polar.kit.trial import TrialConfigurationInputMixin, TrialConfigurationOutputMixin +from polar.kit.validators import validate_http_url from polar.organization.schemas import OrganizationID from polar.product.schemas import ( BenefitPublicList, @@ -28,7 +35,7 @@ ) SuccessURL = Annotated[ - HttpUrl | None, + str | None, Field( description=( "URL where the customer will be redirected after a successful payment." @@ -74,6 +81,11 @@ class CheckoutLinkCreateBase(TrialConfigurationInputMixin, MetadataInputMixin, S ) success_url: SuccessURL = None + @field_validator("success_url") + @classmethod + def validate_is_http_url(cls, url: str | None) -> str | None: + return validate_http_url(url) + class CheckoutLinkCreateProductPrice(CheckoutLinkCreateBase): """ @@ -132,6 +144,11 @@ class CheckoutLinkUpdate(MetadataInputMixin, TrialConfigurationInputMixin): ) success_url: SuccessURL = None + @field_validator("success_url") + @classmethod + def validate_is_http_url(cls, url: str | None) -> str | None: + return validate_http_url(url) + class CheckoutLinkBase( MetadataOutputMixin, TrialConfigurationOutputMixin, TimestampedSchema, IDSchema diff --git a/server/polar/kit/validators.py b/server/polar/kit/validators.py new file mode 100644 index 0000000000..6409e6c403 --- /dev/null +++ b/server/polar/kit/validators.py @@ -0,0 +1,8 @@ +from pydantic import HttpUrl, TypeAdapter + + +def validate_http_url(url: str | None) -> str | None: + if not url: + return None + TypeAdapter(HttpUrl).validate_python(url) + return url diff --git a/server/tests/checkout/test_service.py b/server/tests/checkout/test_service.py index 29be329df6..4bc033a036 100644 --- a/server/tests/checkout/test_service.py +++ b/server/tests/checkout/test_service.py @@ -7,7 +7,7 @@ import pytest import pytest_asyncio import stripe as stripe_lib -from pydantic import HttpUrl, ValidationError +from pydantic import ValidationError from pytest_mock import MockerFixture from sqlalchemy.orm import joinedload @@ -739,9 +739,7 @@ async def test_valid_success_url_with_interpolation( session, CheckoutPriceCreate( product_price_id=price.id, - success_url=HttpUrl( - "https://example.com/success?checkout_id={CHECKOUT_ID}" - ), + success_url="https://example.com/success?checkout_id={CHECKOUT_ID}", ), auth_subject, ) @@ -765,9 +763,7 @@ async def test_valid_success_url_with_invalid_interpolation_variable( session, CheckoutPriceCreate( product_price_id=price.id, - success_url=HttpUrl( - "https://example.com/success?checkout_id={CHECKOUT_SESSION_ID}" - ), + success_url="https://example.com/success?checkout_id={CHECKOUT_SESSION_ID}", ), auth_subject, ) diff --git a/server/tests/checkout_link/test_service.py b/server/tests/checkout_link/test_service.py index d278122c99..fb67d6764d 100644 --- a/server/tests/checkout_link/test_service.py +++ b/server/tests/checkout_link/test_service.py @@ -2,7 +2,6 @@ import pytest import pytest_asyncio -from pydantic import HttpUrl from polar.auth.models import AuthSubject from polar.checkout_link.schemas import ( @@ -159,9 +158,7 @@ async def test_valid( CheckoutLinkCreateProducts( payment_processor=PaymentProcessor.stripe, products=[product_one_time.id], - success_url=HttpUrl( - "https://example.com/success?checkout_id={CHECKOUT_ID}" - ), + success_url="https://example.com/success?checkout_id={CHECKOUT_ID}", metadata={"key": "value"}, ), auth_subject, @@ -192,9 +189,7 @@ async def test_valid_discount( payment_processor=PaymentProcessor.stripe, products=[product_one_time.id], discount_id=discount_fixed_once.id, - success_url=HttpUrl( - "https://example.com/success?checkout_id={CHECKOUT_ID}" - ), + success_url="https://example.com/success?checkout_id={CHECKOUT_ID}", metadata={"key": "value"}, ), auth_subject,