Problem/Motivation

Three findings from a security review before beta7, each confirmed in the code.

  1. api/subscribe-url and api/portal-url are served as text/html. Both build their body with json_encode() and return a bare Response with no content type, which Symfony then labels HTML; json_encode() does not escape angle brackets. The subscribe-url payload carries each plan's name and price description, which arrive from the billing provider through syncPlans(). Anyone who can rename a product in the provider's dashboard, or a plan administrator, can put a script tag in a name; after the next sync, a subscriber who opens the URL (a same-origin link suffices) runs it in the site origin with their session. X-Content-Type-Options: nosniff does not help because the type is explicitly HTML. api/my-subscription already returns a JsonResponse.
  2. api/my-subscription returns the plan's raw prices map verbatim. The entity defines that field as "raw subscription plan prices data", whatever the connector stored from the remote service — provider metadata, lookup keys, internal notes. The serializer's own comment says raw remote payloads must not leak, but exempts this field, and MySubscriptionApiTest asserts that an arbitrary internal_note key round-trips to the subscriber. With #3629242: Let subscribers choose a plan, not a connector: list plans in the subscribe flow and preselect the plan in redirectToSubscribe() the module has a structured, connector-vetted PlanPrice; the API should return that.
  3. The post-purchase token burn is check-then-set. PostPurchaseTokenService::verify() checks whether a signature is burned, then writes the burn. Two presentations of a leaked auto-login token racing within the same instant both verify. The expirable key-value store offers setWithExpireIfNotExists(), which is atomic.

Proposed resolution

  • All six return paths of getSubscribeUrl() and getPortalUrl() return a JsonResponse; the Kernel tests assert application/json on each, including the 503 shapes, and that a plan named with a script tag comes back escaped and typed as JSON.
  • serializePlan() returns price — PlanPrice::toArray() from the plan's connector when it implements PlanPricingConnectorInterface, else null — and no longer returns prices. Breaking for front ends reading plan.prices; same shape as the chooser API's plans[].price. The test's internal_note assertion inverts: it must not appear.
  • The burn becomes setWithExpireIfNotExists(), and verification fails when it returns FALSE; a test presents the same token twice and gets exactly one success.

Remaining tasks

  • Implementation with tests; change record (the price field is the breaking part).

User interface changes

None.

API changes

api/my-subscription's plan.prices is replaced by plan.price ({amount, currency, interval, interval_count, description} or null). api/subscribe-url and api/portal-url gain the application/json content type they should always have had. No connector interface changes.

Data model changes

None.

Comments

colan created an issue. See original summary.

colan’s picture

Priority: Normal » Major