Payments
Published
A payment client is configured with a handful of narrowings, each of which exists because of an incident somebody else had: a key that makes a repeated request harmless, a server-side amount that the browser cannot argue with, a currency, a ceiling, and a verifier that applies your own rules. Every one of those is a property name on an options object, read by a client that arrived from a package. We protected a file that configures one, renamed the names a group at a time, and read the ledger afterwards.
What the sample actually does
The file runs five checkouts. An ordinary one. The same one again, because the customer's connection dropped and the browser retried - which is what actually happens, and nothing in the second request announces that it is a repeat. A checkout where the browser claims a 49.99 cart is worth 99 cents. A genuine 300.00 order from a customer whose profile allows 200.00. And a corporate bulk order of 9,000.00 that clears every customer-level check and meets only the account ceiling.
Those five are not decoration. Each one is built so that exactly one guard refuses it, because a guard that never fires cannot be measured - if the amount verifier had refused the tampered checkout, the pinned server-side amount would have reported nothing at all, truthfully and uselessly.
The client is copied in unprotected, with the defaults this family of packages ships: no idempotency key, trust the amount in the request, the account's own default currency, no ceiling, and an amount check that asks only whether the number is a positive integer.
Protection alone was applied first on five presets and all five behaved identically to the original, so nothing below is caused by protection on its own.
The retry that becomes a second payment
Renaming the idempotency key is the plainest result in this article. In the unprotected run the second request is recognised, the original charge is replayed, and the ledger holds one charge. With the name gone, the client had nothing to match on, so it created a new charge. The ledger held two, the customer had paid twice, and both charges are entirely valid.
Every part of this succeeds. There is no error, no warning and no anomaly for a monitor to notice, because two successful payments from a customer who placed two requests is precisely what the data says happened. It is found by the customer, and it is found again by your payment provider's dispute process a week later.
The reported policy line moved from the key the application supplies to a note that every call is a new charge, which is the client honestly describing its default. Nobody reads that line either.
The amount the browser asked for
The application pins the amount the server computed and also supplies its own verifier, which applies the per-customer limit from the customer's profile. They guard different attacks, and the run is built so that each one refuses a different checkout.
Renaming the pinned amount let the 99-cent claim through: the charge was created, for 99 cents, against a cart worth 49.99. The run prints the shortfall. The client had nothing to compare against, so it charged what it was asked to charge - which, for a payment client, is the correct behaviour and the documented default.
Renaming the verifier is the caller-supplied-guard downgrade this series has now measured in six libraries across four passes. The client fell back to its own check - is it a positive integer - and the 300.00 order from a customer limited to 200.00 was charged. The reported verifier changed from caller-supplied to the library builtin. Both checks still ran. One of them was replaced by a much weaker one that answers a different question.
Renaming both is the arm that puts three charges on the ledger that should not be there, and it is the realistic one: a MemberRegexp scoped to the payment options object takes every name in it at once.
Currency and the ceiling
The application is priced in euros and pins the currency to match. Renaming that option moved every charge to the client's default, and the ledger line shows the same numeric amount settled in a different currency. Nothing was refused and nothing looked wrong: the amount is right, the currency is not, and the reconciliation that finds it runs monthly.
That arm also carries the general warning about controls. This measurement's default happens to be a currency worth slightly more than the configured one, so the customer overpays. Turn the pair around - a euro-priced product settled in a currency worth a fifth as much - and the same rename is straightforward revenue loss at a rate nobody notices until the month closes.
The ceiling is the loudest arm here and still the one worth keeping. With it, the 9,000.00 bulk order is refused. Without it, the client has no ceiling at all, and the charge was created. An account-level maximum is the control that turns a compromised or mistaken upstream into a bounded problem, and it costs one property name.
One option was pinned to a value identical to the client's default as a control, and renaming it changed nothing, exactly as predicted - the ninth consecutive pass in which that prediction has held for every control in the file.
The names your own code owns, and where they stop being safe
The order identifier, the customer identifier and the cart total are names this application writes and reads on both sides, so every decision in the run was unchanged when they were renamed. The charge was still correct, the retry was still recognised, the limits still applied.
The metadata attached to the charge at the provider was not. It went from the two fields the application chose to a pair of generated names. Inside the process nothing had changed; on the provider's record, in every export, in every reconciliation query, in the dashboard the finance team uses to match a charge back to an order, the field that says which order this is had a name nobody recognises.
That is the boundary rule this series keeps arriving at from different directions: a name your own code owns on both sides is safe inside the process and stops being safe the moment it is serialised into anything that leaves it. A payment record is about as durable an interface as an application has - it outlives the deploy, the release and often the vendor.
The opposite direction is loud, as usual. Renaming the result fields the client returns made the charge identifier, the status and the currency read as undefined, so the application's own confirmation line described a charge it could not name. That is the arm your smoke test finds in the first minute.
What to do about it
The mechanism is not specific to payments and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. A payment client installed from a package is not inside that code, so a renamed option name is a name it has never heard of, and it applies its documented default - which for a payment client means charging what it was asked to charge, once per request.
Scope the renaming. The options object handed to a payment client, and the metadata object it carries, belong outside the MemberRegexp. If you would rather not maintain an exclusion list, build them with quoted string keys and read them back with literal bracket access.
Then assert at the boundary rather than in the flow. Before the call, require the intent to carry an idempotency key and a server-computed amount, and refuse to proceed without them - a guard that fails closed cannot be lost quietly. After the call, compare the charge the provider reports against the order you computed: the amount, the currency and the metadata field naming the order. That comparison fails on every arm in this article except the control.
A checkout test that buys something and sees a success page passes on all of them, including the one where the customer is charged twice.
Frequently asked questions
Does protecting my JavaScript break payment processing on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the option names.
How does a renamed option produce a double charge?
The idempotency key is what lets a payment client recognise a repeated request and replay the original result. With the property name gone the client has nothing to match on, so a retried checkout is simply a second checkout, and both charges are valid.
Can the browser really choose the amount?
Only if nothing on the server pins it. The application pins the amount its own database computed; renaming that option removed the comparison, and a request claiming 99 cents for a 49.99 cart was charged as 99 cents.
What is the difference between the pinned amount and our own verifier?
They answer different questions, which is why the measurement gives each one a scenario that only it refuses. The pinned amount catches a tampered total; the verifier applies your own rules, in this case the customer's profile limit. Losing either leaves a different gap.
Why does the currency arm matter if the number is right?
Because the number is denominated. The same amount settled in the wrong currency is a silent revenue difference in one direction or a silent overcharge in the other, and it is normally found by a monthly reconciliation rather than by anything in the request path.
Our order identifiers are only ever used by our own code. Are they safe?
Inside the process, yes - every decision in the measurement was unchanged. On the provider's record they were not: the charge metadata came back carrying generated names, so anything reconciling a charge to an order sees a field it does not recognise.
What is the recommended fix?
Exclude the payment options object and its metadata from member renaming, or build both with quoted string keys and literal bracket reads. Then refuse to call the provider without an idempotency key and a server-computed amount, and compare the charge the provider reports against the order you computed.
Related reading