From b4fc82093a5ea36921750cb3b07aec12a001d744 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Mon, 15 Sep 2025 20:45:29 +0530 Subject: [PATCH 1/3] fix: Unescape checkout_id placeholder in success URLs --- ...17_unescape_checkout_id_placeholder_in_.py | 56 +++++++++++++++++++ server/polar/checkout/schemas.py | 15 ++++- server/polar/checkout_link/schemas.py | 21 ++++++- server/polar/kit/validators.py | 8 +++ server/tests/checkout/test_service.py | 8 +-- server/tests/checkout_link/test_service.py | 8 +-- 6 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py create mode 100644 server/polar/kit/validators.py diff --git a/server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py b/server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py new file mode 100644 index 0000000000..7454a5734a --- /dev/null +++ b/server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py @@ -0,0 +1,56 @@ +"""Unescape checkout_id placeholder in success URLs + +Revision ID: 02af7a82e76c +Revises: c26960e60bda +Create Date: 2025-09-14 21:17:08.166830 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "02af7a82e76c" +down_revision = "c26960e60bda" +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 bb4e50f1c0..edf7723461 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 @@ -41,6 +41,7 @@ SetSchemaReference, TimestampedSchema, ) +from polar.kit.validators import validate_http_url from polar.models.checkout import ( CheckoutBillingAddressFields, CheckoutCustomerBillingAddressFields, @@ -90,7 +91,7 @@ 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." @@ -195,6 +196,11 @@ class CheckoutCreateBase(CustomFieldDataInputMixin, MetadataInputMixin, Schema): 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): """ @@ -314,6 +320,11 @@ class CheckoutUpdate(MetadataInputMixin, CheckoutUpdateBase): 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 9b329ca98c..70833e49a4 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 @@ -16,6 +22,7 @@ Schema, TimestampedSchema, ) +from polar.kit.validators import validate_http_url from polar.organization.schemas import OrganizationID from polar.product.schemas import ( BenefitPublicList, @@ -26,7 +33,7 @@ ) SuccessURL = Annotated[ - HttpUrl | None, + str | None, Field( description=( "URL where the customer will be redirected after a successful payment." @@ -72,6 +79,11 @@ class CheckoutLinkCreateBase(MetadataInputMixin, Schema): ) 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): """ @@ -129,6 +141,11 @@ class CheckoutLinkUpdate(MetadataInputMixin): ) 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, IDSchema, TimestampedSchema): payment_processor: PaymentProcessor = Field(description="Payment processor used.") 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 97c8fa0d57..a56a84fc57 100644 --- a/server/tests/checkout/test_service.py +++ b/server/tests/checkout/test_service.py @@ -694,9 +694,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, ) @@ -720,9 +718,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..3ee52aefc4 100644 --- a/server/tests/checkout_link/test_service.py +++ b/server/tests/checkout_link/test_service.py @@ -159,9 +159,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 +190,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, From feef346ea32cf44f018da9077561c428c52a1f7d Mon Sep 17 00:00:00 2001 From: Dhanus Date: Mon, 15 Sep 2025 20:50:35 +0530 Subject: [PATCH 2/3] Lints --- server/tests/checkout/test_service.py | 2 +- server/tests/checkout_link/test_service.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server/tests/checkout/test_service.py b/server/tests/checkout/test_service.py index c6b0e6f431..b20ba8cc23 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 diff --git a/server/tests/checkout_link/test_service.py b/server/tests/checkout_link/test_service.py index 3ee52aefc4..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 ( From 46e9082a98234160fa7c605e96037ca82a906c21 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Mon, 15 Sep 2025 21:13:56 +0530 Subject: [PATCH 3/3] Migrations --- ...9-15-2112_unescape_checkout_id_placeholder_in_.py} | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) rename server/migrations/versions/{2025-09-14-2117_unescape_checkout_id_placeholder_in_.py => 2025-09-15-2112_unescape_checkout_id_placeholder_in_.py} (89%) diff --git a/server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py b/server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py similarity index 89% rename from server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py rename to server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py index 7454a5734a..c38f25c299 100644 --- a/server/migrations/versions/2025-09-14-2117_unescape_checkout_id_placeholder_in_.py +++ b/server/migrations/versions/2025-09-15-2112_unescape_checkout_id_placeholder_in_.py @@ -1,8 +1,8 @@ """Unescape checkout_id placeholder in success URLs -Revision ID: 02af7a82e76c -Revises: c26960e60bda -Create Date: 2025-09-14 21:17:08.166830 +Revision ID: b797366de1fb +Revises: bf0f120ca9a9 +Create Date: 2025-09-15 21:12:39.367225 """ @@ -12,12 +12,11 @@ # Polar Custom Imports # revision identifiers, used by Alembic. -revision = "02af7a82e76c" -down_revision = "c26960e60bda" +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}"