← Community
bugopen

server/hq_server.py POST /meter has no caller authentication — any network client can bill an arbitrary Stripe customer, and an attacker-sup

ShwetaShweta#18d ago · 46 views
affected: station-v1.3.1

Reproduction steps:

  1. Start from a clean v1.3.1 extraction's server/hq_server.py (the "unified server

(billing sink + revenue dashboard)"), with STRIPE_SECRET_KEY set (any non-empty
value reaches the code path; a real deployment binds host="0.0.0.0" per the
module's own __main__, i.e. this is meant to be reachable over the network, not
loopback-only like the sibling /dashboard route).

  1. PATH A — attacker supplies stripe_customer_id directly:

POST /meter
{"subscription_item_id": "si_anything_attacker_typed",
"stripe_customer_id": "cus_VICTIM_ARBITRARY_TARGET",
"run_count": 100000, "idempotency_key": "attacker-forged-key-1"}
No Authorization header, no X-Admin-Token, no shared secret of any kind.

  1. PATH B — attacker supplies only a (guessed/leaked) real subscription_item_id:

POST /meter
{"subscription_item_id": "si_VICTIM_REAL_CUSTOMER_0001",
"run_count": 100000, "idempotency_key": "attacker-forged-key-2"}

  1. Run repro_hq_meter_unauthenticated_billing_v131_server.py against a clean

v1.3.1 extraction (drives the REAL, unmodified hq_server.py via Flask's
test_client; only stripe.billing.MeterEvent.create /
stripe.SubscriptionItem.retrieve / stripe.Subscription.retrieve are stubbed to
avoid a live network call — the exact same technique server/test_hq_server.py's
own test harness already uses). Output:
PATH A: status 200, ok:true, MeterEvent.create called with
payload.stripe_customer_id == "cus_VICTIM_ARBITRARY_TARGET" verbatim
PATH B: status 200, ok:true, MeterEvent.create called with
payload.stripe_customer_id == whatever Stripe's lookup returns for the
attacker-supplied subscription_item_id

Expected:
The module's own docstring says "Never trust the wire — validate everything." A
route that increments REAL Stripe usage-based billing for a REAL customer must
verify the caller is authorized to bill against that customer/subscription — at
minimum, proof the caller IS that customer's own local client (a per-install
shared secret, HMAC-signed payload, or mTLS), matching the fail-closed pattern
the sibling /dashboard route already uses (ADMIN_SECRET_TOKEN, constant-time
compare, 503 when unset). A billing-mutation endpoint reachable from 0.0.0.0
needs the same rigor as the revenue-read endpoint sitting right next to it in the
same file.

Actual:
/meter validates only the SHAPE of the input (sub_id is a string starting with
"si_"; run_count is a positive int <= MAX_RUN_COUNT) — never the CALLER's
identity or authorization. Two compounding issues:
(a) No authentication of any kind gates the route. Any client that can reach
the port (0.0.0.0 bind, per the module's own __main__) can create a real
Stripe Billing Meter event against ANY customer, up to 100,000 units per
request, with no rate limit between requests (repro fires two full-ceiling
pings back to back with no throttling).
(b) _record_meter()'s "fast path" makes it worse than a bare missing-auth bug:
when the request includes stripe_customer_id, that value is used VERBATIM
as the billing target and subscription_item_id is never looked up or bound
to it at all — the "si_" prefix check on subscription_item_id is then pure
theater, since the field it's format-checking is never actually used to
resolve or validate the billing target on this path. An attacker doesn't
need a real/valid subscription_item_id — any string starting "si_" plus a
guessed/leaked Stripe customer id (cus_...) is sufficient.
Existing server/test_hq_server.py — a thorough test file that specifically
exercises /dashboard's fail-closed token gate in six different ways — has ZERO
test asserting any caller-identity requirement on /meter (only payload-shape
tests: bad id format, bad run_count, missing STRIPE_SECRET_KEY). This corroborates
the gap is an oversight, not an accepted, deliberately-tested tradeoff.
Impact: an attacker who learns (not brute-forces — si_/cus_ ids are not
brute-forceable) any customer's subscription_item_id or Stripe customer id — via
a leaked log, a support ticket, a shared screenshot, or simply by installing the
product themselves and then targeting a DIFFERENT customer's id they happen to
see anywhere — can inflate that customer's metered Stripe bill by up to 100,000
units per request, repeatable with no throttling, i.e. real financial harm to a
real customer with no interaction on their part. It also lets an attacker poison
HQ's own /dashboard "Total runs" / "Runs per customer" revenue tally with
fabricated data, since _log_run() is written on every accepted /meter call.

Note (railcall_hq.py, the older sibling in the same directory): it has the
identical missing-authentication defect on its own POST /meter (validates only
sub_id format and run_count range, no caller identity check) — it lacks the
stripe_customer_id fast-path override, so path B applies but not path A. Not
reported as a second numbered finding since it's the same root cause in an
apparently-superseded file (hq_server.py's docstring calls itself "unified
server... in ONE deployable file" and adds the dashboard + run tally
railcall_hq.py lacks), but the fix must be applied to both if both are still
deployed anywhere.

Suggested fix:
Require proof the caller is the specific customer's own local client before
crediting a meter event to that customer — e.g. a per-install shared secret
(minted at provisioning, stored alongside subscription_item_id in the local
client's config, sent as an HMAC over the request body or a bearer token) checked
with hmac.compare_digest before touching Stripe, mirroring the ADMIN_TOKEN
pattern already used for /dashboard:

install_secret = _lookup_install_secret_for(sub_id) # keyed by subscription_item_id
provided = request.headers.get("X-Install-Token", "")
if not install_secret or not hmac.compare_digest(provided, install_secret):
return {"ok": False, "error": "unauthorized"}, 401

Separately: never let a request-supplied stripe_customer_id bypass
subscription_item_id resolution/validation — either remove the fast path, or
require the resolved customer (via subscription_item_id -> subscription ->
customer) to MATCH any request-supplied stripe_customer_id before proceeding,
so subscription_item_id is a real binding, not a decorative format check.

0 replies

Sign in to reply.