02 / Findings
6 findings, worst first
Ordered by what would cost you the most money or trust.
Critical
Stock is never reserved before payment, so two buyers can buy the same last item
File src/api/checkout.ts · Line 16 · Priority Critical — fix before you sell again · Estimated fix time 3–5 hours including tests
Problem
The code reads the stock level, then charges the customer, then lowers the stock. Between the check and the update there is nothing stopping a second request from reading the same stock level and charging a second customer for the same item. The check and the change are two separate steps with a network call in between.
Evidence
16 if (item.stock > 0) {
17 await stripe.charge(amount, email);
18 await db.query(
19 'UPDATE products SET stock = stock - 1',
Impact
Two customers can pay for the same last unit. One of them gets a refund and an apology, the other gets an email they did not expect. On limited or scarce items this happens on launch day, exactly when you cannot afford it. Refunds also cost you payment fees and support time.
Suggested fix
Reserve the item inside a single database transaction before you charge, using row locking (for example SELECT ... FOR UPDATE) or an atomic conditional update that only succeeds while stock is still available. Then charge, then turn the reservation into a real stock reduction. Reserving alone is not a full repair: the failed path has to be handled too — if the payment fails or times out, release the reservation so the item goes back on sale; if the payment succeeds, confirm it once and never twice; give every attempt an idempotency key so a retry cannot charge twice; and return a clear “sold out” response to the customer instead of a silent success. Without those steps you either lose stock or still sell the same item twice.
High
The price comes from the browser, so the customer chooses what to pay
File src/api/checkout.ts · Line 8 · Priority High — fix before you sell again · Estimated fix time 1–2 hours
Problem
The amount to charge is taken straight from the request body, and it is passed to the payment call without ever being compared to the price stored in the database. Whatever number the browser sends is the number that gets charged.
Evidence
8 const { productId, amount, email } = req.body;
9
10 const item = await db.query(
11 'SELECT * FROM products WHERE id = $1',
12 [productId]
13 );
14 if (!item) return res.status(404).json({ error: 'not found' });
15
16 if (item.stock > 0) {
17 await stripe.charge(amount, email);
Impact
Anyone can change the amount in their browser or with a simple script and buy a $299 product for $0.01. This is direct revenue loss, and it is easy to find.
Suggested fix
Look the price up on the server from the product id and charge that. Never trust a price, a discount or a total that came from the client. Validate the amount and the currency server-side and log any mismatch between what the client sent and what the database says.
High
SQL injection in the product search
File src/api/search.ts · Line 5 · Priority High — fix before you sell again · Estimated fix time 1–3 hours (plus a sweep of the other queries)
Problem
The search term is pasted directly into the SQL string. A quote in the search box breaks out of the string and lets the caller run their own SQL.
Evidence
5 `SELECT id, name, price FROM products WHERE name LIKE '%${q}%'`
Impact
An attacker can read your whole database, change prices, delete rows or dump customer data. This is one of the most exploited bugs on the web and it is usually found automatically by scanners.
Suggested fix
Use parameterised queries or a query builder, so the input can never become part of the SQL statement. The checkout route in this same project already does that for the product lookup; apply the same pattern here: pass the pattern as a bound value instead of building it into the string. Validate and limit the length of the search term as well, and check whether the same pattern appears in other queries in the project.
Critical
A live secret payment key is shipped to the browser
File public/js/config.js · Line 4 · Priority Critical — revoke the key today · Estimated fix time 1–2 hours
Problem
A payment secret key is written into a JavaScript file that every visitor downloads. Anything in front-end code is public, no matter how the file is named. A secret key is not a publishable key: it can create charges and read customer and payment data for the whole account.
Evidence
4 stripeSecret: 'sk_live_51Hq9xK2eZv…',
Impact
Anyone who opens the page source can read the key and use it. Unlike a leaked publishable key, this one acts as the account itself: charges can be created on your account, customer and payment records can be read, and the key can be used to issue refunds. Most providers also rotate keys automatically when they detect this pattern, which turns a live shop into a broken one without warning. Treat it as a live credential that is already in the wrong hands.
Suggested fix
Move the key to the server and let your own API talk to the payment provider; the browser should never hold a secret key. Revoke the current key at the provider now and issue a new one, then check the provider dashboard for calls you did not make. If something has to be in the browser, make sure it is the publishable key and not the secret one — and never commit either to a public repository.
Medium
The charge runs without error handling, so a failure after it leaves the customer charged
File src/api/checkout.ts · Line 17 · Priority Medium — fix this week · Estimated fix time 2–4 hours
Problem
The charge is not wrapped in a try block: a declined card ends the request with a generic error and no explanation or retry path. The reverse case is the costly one. The charge runs first, so if the stock update or the order insert after it fails, the customer has been charged while no order and no stock change are recorded, and nothing in the code reverses the payment.
Evidence
17 await stripe.charge(amount, email);
18 await db.query(
19 'UPDATE products SET stock = stock - 1',
20 [productId]
21 );
22 const order = await db.query(
23 'INSERT INTO orders (email, total) VALUES ($1, $2) RETURNING id',
Impact
A declined card gives the customer a generic error instead of a clear message, so they retry and can end up paying twice. A customer who was charged with no order to match has to be refunded by hand, after they complain, and the logs hold no list of which payments were affected.
Suggested fix
Wrap the charge in a try block and decide what happens on failure: send a clear message to the customer, leave the stock untouched and log the attempt with enough context to trace it. Write the order row together with the charge so a paid charge always has an order to match, and reconcile the provider afterwards so a payment without a completed order stands out. Confirm payments idempotently, so a repeated callback or a retry cannot charge or confirm the same order twice.
Low
An empty catch block hides failures
File src/lib/email.ts · Line 8 · Priority Low — plan it in · Estimated fix time 30–60 minutes
Problem
The order confirmation email is sent inside a try block with an empty catch. If sending fails, nothing is logged and nothing is reported. The checkout route calls this helper on every order, so the failure is invisible on every order.
Evidence
6 try {
7 await mailer.send(order.email, subject, body);
8 } catch (e) {}
Impact
Customers do not get their confirmation and nobody finds out. Support starts asking why people say they never received an email, and there is no trace to debug with.
Suggested fix
Log the error with the order id and enough context to reproduce it, and decide per case whether a failure should be retried, queued or surfaced to the customer. Keep the catch block, just stop it from swallowing the problem.
Total6 findings in one checkout flow and the files it touches. Estimated repair time together: 8.5–17 hours, of which the 4 critical and high items should be fixed before the shop takes another order.