Charming Seal·Blog·The audit
Reading all 103 routes. Then printing what was wrong.
Before Charming Seal went public, every API route in it was read end to end. The findings sort into five kinds of mistake, one of them serious. Three items are still open, and those are at the bottom.
The audit was one pass over 103 routes, each read to the end before the next. Reading them together, rather than repairing routes as complaints arrive, changes what becomes visible. Individual bugs show up either way. Shapes do not. A mistake in the first handler of a family gets copied into the next eleven, because the first one was the example everybody worked from. So the findings below are grouped by kind, not by line number: five shapes to look for in route 104.
One key, two shops
The worst finding was tenant isolation. One deployment can hold several shops, and an API key belongs to exactly one of them. A handler that takes a product id from the caller and looks it up by that id alone returns the product whoever owns it. In practice one shop's key could read another shop's products and reprice them.
It is closed, and closed with a before-and-after test: one that fails against the old code and passes against the new. That is a claim the reader can run. A sentence on a website is not.
The repair that tempts you is an ownership check in each handler missing one. It works on the day you do it and leaves every later route one forgotten line from the same hole. The check belongs in one place, where the key becomes an identity. That identity then travels with the query, so a handler cannot ask a question that is not already scoped to the shop asking it.
// Illustrative shapes, not code from the repository. // The shape that lost: the id came from the caller, and nothing else did. product = db.product.find({ id: request.productId }) // The shape that holds: the tenant comes from the key, on every query. product = db.product.find({ id: request.productId, shopId: auth.shopId })
Money the client asked to pay
A checkout page knows the price, the discount code, whether the order bump was ticked, and which currency is showing. Every one of those numbers sits in a browser the seller does not control, so none of them are accepted. Price, discounts, bumps and currency are recomputed on the server before anything is charged. The request says what the buyer wants. The server decides what it costs.
Currency is the item easiest to leave off that list. A total means nothing without its unit, and an order charged in the wrong one clears perfectly cleanly. Nothing in this class throws: the order succeeds, the receipt reads normally, and the only record of the problem is the amount that arrived.
Encrypted at rest, and the rest of the sentence
A Stripe secret key has to be used, so it is encrypted at rest with AES-256-GCM and decrypted at the moment of use. An API key issued by a shop never has to be read back. Verifying one only requires knowing whether the string presented matches the string issued, which a hash answers. So those are stored as hashes and nothing else, which is why a new key is shown once in Settings → API Keys and never again.
Now the honest part. "We store it encrypted" carries no information on its own. It is a claim about where a secret went, worth as much as the answer to the next question: where the decrypting key lives. Encryption relocates a secret. It does not retire one. For self-hosted software the answer is plain and mildly uncomfortable: that key sits in your environment, on your host, under your account.
A session the browser cannot read
Sessions were rewritten to random tokens with server-side revocation. A token carrying its own contents can be verified without asking anybody, which reads as an advantage until you want it to stop working. Then there is nothing to delete, and the honest answer is to wait for it to expire. A random token means the browser holds a reference and the server holds the record, so revoking is deleting a row, and it takes effect on the next request.
A regex is not a parser
The old HTML sanitiser was built from regular expressions and has been replaced with a real parser. HTML is not a regular language, so a pattern sanitiser amounts to a list of the attacks its author thought of before they stopped writing patterns. Everything outside the list goes through. A parser reads the markup into a tree, and what may remain is decided against that tree, where the structure is known instead of guessed.
Outbound requests are the same problem facing the other way. Any URL a merchant supplies can aim back inside the network the server sits in, so outbound fetches resolve the name to an address before connecting and refuse the private ranges.
What is still open
Three things are unfinished. They stay printed here, and on the homepage, until they stop being true.
A revoked session renders the page shell until it expires. Every API call behind that shell fails, so no data is served into it, but the frame of the page is still drawn for somebody whose access has been taken away. A wrong impression rather than a leak. Still wrong.
The script-source policy ships report-only. Violations are recorded and not blocked, so the policy reports what it would have stopped without stopping it. Recorded is not the same as prevented.
DNS rebinding is narrowed, not eliminated. A name can answer with a public address when it is checked and a private one when it is fetched. That window has been made small. It has not been shut.
Why that is the section worth reading
Every security page lists what was fixed. That list costs nothing to write and the reader cannot check it. The open items above are the part that says something about how the work was done, because nobody prints those unless they mean it. Every codebase of this size has some. Printing them is a choice.
If you find a sixth shape, report it privately through SECURITY.md rather than as a public issue. All of it is in the source under an MIT licence, for anybody who would rather check than take a website's word.
Found something
Report it privately. Never as a public issue.
The disclosure policy lives in the repository and is the right first stop for anything dangerous while it is unpatched. Everything else, the open items included, is in the source to read.