The Payment Page Had a Front Door

The payment receipt page took an invoice number from the payment service’s message and dropped it straight into a database lookup for a registration, with no check that it was a number at all. Anyone who knew the page’s address could send their own value in that spot, and the database would treat it as part of the question it was being asked. That is a SQL injection risk, and I found it while I was busy fixing something else entirely: receipts that kept finding the wrong registration.

Then one line made me stop.

The page took an invoice number from the payment service’s message and used it directly to ask the database for a registration. In ordinary language, it was like taking the number written on a package label and using it to open a filing cabinet without first checking that it was actually a number. That line had a SQL injection risk: the name sounds bigger than it is, and it means somebody can send words where a number belongs, and those words can change the question your database thinks it has been asked.

I had missed it because of the story I was telling myself. The payment service processed the charge, then sent information back to a page that showed the receipt. If the payment service was the expected sender, surely the number inside its message was safe. It was not a private hallway. It was a public front door.

The page had to be reachable from the outside so the payment service could send its result. But anything else on the internet could knock on that same door. There was no check in place proving the message actually came from the payment service; no signature configured, nothing verified. The address itself was not secret. Anyone who knew it could send their own message with their own invoice number, and my code would have treated it exactly the same as the real thing.

The first thing I had tried, hours earlier, was to keep chasing the receipt mix-up. That was a real, customer-facing problem, and it got its own fix. But it had taken hours of patching and rereading the same handler, and the cost was not just time. It was the attention that comes from being deep in one emergency. I was so busy asking why the page found the wrong registration that I never asked whether an outsider could tell the page what registration to find. Two different numbers, the registration ID and the payment provider’s own transaction ID, had at some point been treated as interchangeable in that same handler, as a kind of safety net. Treating them as the same thing was itself part of what let the deeper problem sit there unnoticed for as long as it had.

Once I saw the real boundary, the fix was small. The registration number had one hard rule: it had to be a positive whole number. So the page converted the incoming value to a number and stopped if it wasn’t one. A value made of letters or symbols never reached the database. A real registration number still worked exactly as before. That mattered for more than security: before the check, a bad value could produce a database error or a quiet lookup of the wrong record. After it, the page gave one plain message, that payment could not be confirmed and to contact the administrator, and nobody was ever shown a receipt that belonged to someone else.

I did not rebuild the whole integration in the middle of a payment problem, and I wouldn’t recommend it either. The short-term move was a check at the door. The longer job, making the door actually prove who was visiting using the verification method the payment provider offers, came after.

The five lines worked because they moved the trust decision to the one place it belonged: the moment the invoice number arrived at the front door. Before them, a value of letters and symbols went straight into the registration lookup, and my code would have treated a stranger’s message the same as the payment service’s own. After them, only a positive whole number could ever reach the database, and everything else got one plain message and no one else’s receipt. The check does not prove who sent the message. That job still belongs to the signature the payment provider offers, and it is the reason the door is only half closed.