Skip to content Earthshaker Security Services Methodology Pricing Sample report FAQ About Blog Book a scan Sanitized · read-only

Engagement ES-2026-0114

A Pro engagement with an invented client, written exactly the way a real one is. Eight findings — one High, no Critical, which is what a competently built store usually scores. Every section below appears in the report you receive, in this order.

Your copy arrives as an AES-256 encrypted PDF, with the passphrase sent separately. Download this one as a PDF — same pipeline, unencrypted because it is a sample.

Executive summary

ACME Commerce is soundly built, with one finding that costs money and three worth a sprint. Nothing we found exposes cardholder data, and nothing gives an attacker a path to full compromise. The headline items: a customer can stack a discount code until an order total falls to near zero and check out at that price; guest order tracking can be walked with a sequential order number and a common surname; and demoting a staff member leaves their session privileged for up to eight hours.

The things that most often go wrong in a store this size did not go wrong here. Card data never reaches your origin. The admin panel is IP-restricted and enforces MFA. We put 340 injection payloads through every parameter we could reach and found no SQL injection, no command injection and no server-side template injection.

The two Low findings and both Informational findings are hygiene. We would fix the Lows in a backlog sprint and would not lose sleep over the Informational pair — and we have said so below rather than padding the count.

FILTER All 8 Critical 0 High 1 Medium 3 Low 2 Info 2 8 of 8 shown High Fixed-amount discount codes stack without limit ES-0114-01 · POST /api/v1/cart/coupon ▲ close
Impact

A fixed-amount code can be applied to the same cart over and over. The server checks that the code exists and is in date, but never that it is already attached, and never floors the resulting total. Twelve applications of a $15 code took a $172.40 cart to $0.01 — and the order completed, was charged, and entered fulfilment as a normal paid order. No tooling, no special access: a customer clicking "apply" twelve times reproduces it.

The fix

Enforce the constraint in the cart service rather than the checkout controller: reject a coupon already present on the cart, cap total discount at the order subtotal, and floor the payable amount at a configured minimum. Add a regression test that applies the same code twice and asserts the second call returns 409.

Evidence
POST /api/v1/cart/coupon {"code":"WELCOME15"} → 200 {"subtotal":"172.40","discount":"15.00","total":"157.40"} ... same request × 11 ... → 200 {"subtotal":"172.40","discount":"172.39","total":"0.01"} POST /api/v1/checkout → 201 {"order":"ACME-80412","status":"paid"} → order ACME-80412 charged $0.01 and moved to fulfilment. → proof only: test account, test card, cancelled with the client at 16:05 UTC.
OWASP: A04 Insecure Design Fix effort: Small — one server-side constraint Verified by hand: yes Medium Guest order tracking is walkable with an order number and a surname ES-0114-02 · GET /orders/track ▲ close
Impact

Order numbers increment predictably and the surname check is case-insensitive, with no lockout behind a 60-per-minute edge throttle. Pairing a guessed order number with the fifty most common surnames in the store’s market returns, on a hit, the delivery address, the line items and the total. Reach is bounded — one order per successful guess, no account access, no payment data — which is why this is Medium rather than High. But it is an unauthenticated read of customer addresses, and the throttle is a speed bump rather than a control.

The fix

Stop treating the surname as a secret. Issue a signed, single-order lookup token in the confirmation email and make that the only way in. If you keep the form, add per-IP and per-order lockout after five failed attempts and switch to non-sequential order numbers.

Evidence
GET /orders/track?order=ACME-80404&surname=okafor → 404 "not found" GET /orders/track?order=ACME-80404&surname=REDACTED → 200 {"order":"ACME-80404","placed":"2026-01-13", "ship_to":{"line1":"REDACTED","city":"REDACTED","post":"REDACTED"}, "items":[{"sku":"REDACTED","qty":2}],"total":"64.00"} → 300 lookups in 5m12s from one address: no 429, no lockout, no alert. → we stopped at 300, against an order we had placed ourselves.
OWASP: A01 Broken Access Control Fix effort: Medium — needs a token flow Verified by hand: yes Medium Staff role changes do not take effect until the session expires ES-0114-03 · PATCH /api/v1/staff/{id} ▲ close
Impact

Staff authorization is read from a claim inside an eight-hour JWT and never re-checked against the database. Demoting a staff member — or deactivating them entirely — updates the record but leaves their issued token fully privileged until it expires. The realistic case is an offboarding: the moment you most need a role change to take effect is the moment it does not.

The fix

Check role and account status server-side on every privileged request rather than trusting the claim, or keep the claim and maintain a revocation list the auth middleware consults. Shorten the access-token lifetime and issue refresh tokens so a change lands within minutes.

Evidence
PATCH /api/v1/staff/4471 {"role":"fulfilment"} → 200 {"role":"fulfilment"} GET /api/v1/staff/4471 → 200 {"role":"fulfilment"} # same JWT, issued while the account was still admin: POST /api/v1/admin/discounts {"code":"ES-PROOF"} → 201 created GET /api/v1/admin/customers/export → 200 (14,802 rows) → privileged for 7h41m after demotion. → proof only: code created inactive and deleted at 15:20 UTC; the export was requested with Range: bytes=0-2047 and not retained.
OWASP: A01 Broken Access Control Fix effort: Medium — re-check server-side Verified by hand: yes Medium Payment webhook accepts unsigned callbacks ES-0114-04 · POST /hooks/payments ▲ close
Impact

The payment provider signs every callback with an HMAC. Your handler does not verify it — it reads the body and updates the order. The only thing between a forged callback and an order marked paid is an IP allowlist covering the provider’s published ranges. Those ranges are shared, documented infrastructure, so the control is real but weak: an attacker needs an origin inside them rather than a secret.

The fix

Verify the HMAC against the raw request body before parsing it, using a constant-time comparison, and reject on mismatch. Keep the IP allowlist as defence in depth rather than as the control. Treat the amount in the callback as untrusted and reconcile it against the charge you created.

Evidence
POST /hooks/payments X-Signature: sha256=00000000000000000000000000000000... {"event":"charge.succeeded","order":"ACME-80409","amount":"0.00"} → 200 {"ok":true} → order ACME-80409 transitioned unpaid → paid on a signature of all zeroes. → proof only: test order, reverted with the client at 11:40 UTC.
OWASP: A08 Software & Data Integrity Failures Fix effort: Trivial — verify the HMAC Verified by hand: yes Low Session cookie sets Secure and HttpOnly but not SameSite ES-0114-05 · Set-Cookie: acme_sid ▲ close
Impact

The session cookie is correctly Secure and HttpOnly. SameSite is absent, so browsers fall back to their own default — Lax in current Chrome and Firefox, but not guaranteed and not uniform across embedded webviews. Combined with two state-changing form posts that carry no CSRF token, that leaves a narrow forgery window on older and embedded clients.

The fix

Set SameSite=Lax explicitly, and add a synchroniser token to /account/address and /account/preferences — the two posts that rely on cookie auth alone.

Evidence
HTTP/2 200 OK Set-Cookie: acme_sid=REDACTED; Path=/; Secure; HttpOnly; Expires=Fri, 20 Mar 2026 → no SameSite attribute. → /account/address and /account/preferences accept a cross-origin form post with no token; /account/email and /account/password correctly reject it.
OWASP: A05 Security Misconfiguration Fix effort: Trivial — one attribute, two tokens Verified by hand: yes Low Internal service hostname disclosed in an error header ES-0114-06 · X-Upstream on 5xx from api.shop.example ▲ close
Impact

When an upstream service errors, the edge passes through an X-Upstream header naming the internal service and its private DNS suffix. The response body is correctly generic — a bare error and a correlation id, no stack trace — so the leak is limited to your internal naming scheme. Reconnaissance value only; nothing behind those names is reachable from outside.

The fix

Strip X-Upstream at the edge on responses leaving your network. Keep it on the internal hop, where it is useful for tracing.

Evidence
GET /api/v1/orders/not-a-valid-id HTTP/2 502 x-upstream: svc-checkout.internal.acme x-correlation-id: 0f3c1d9a-... {"error":"upstream_unavailable","correlation_id":"0f3c1d9a-..."} → body is clean; the header is not. → svc-checkout.internal.acme does not resolve from outside the network.
OWASP: A05 Security Misconfiguration Fix effort: Trivial — strip at the edge Verified by hand: yes Info Staff role names and shipping-rate matrix disclosed unauthenticated ES-0114-07 · GET /api/v1/config ▲ close
Impact

The unauthenticated bootstrap config returns every staff role identifier, the shipping-rate matrix and the enabled feature flags. None of it is customer data and none of it is exploitable alone, but it hands an attacker your authorization vocabulary — useful when probing for mass assignment or privilege escalation, which is exactly what we tried next (and it held).

The fix

Trim the public config to what the storefront actually needs to render. Move role identifiers and feature flags behind the authenticated session.

Evidence
GET /api/v1/config {"roles":["admin","fulfilment","support","readonly"], "flags":{"express_checkout":true,"subscriptions":false}, "shipping":{"standard":"4.99","express":"12.99","free_over":"75.00"}}
OWASP: A05 Security Misconfiguration Fix effort: Trivial — trim the payload Verified by hand: yes Info lodash 4.17.20 in the client bundle, no reachable sink ES-0114-08 · /assets/vendor.[hash].js ▲ close
Impact

CVE-2021-23337, command injection via _.template. We mapped every call site in the bundle: _.template is not used, and no user-controlled object reaches _.merge or _.set. Not exploitable as the application stands. We report it because a dependency audit will flag it, your customers’ security questionnaires will ask about it, and it is one refactor away from mattering.

The fix

Bump to 4.17.21. Add dependency scanning to CI so the next one of these arrives as a pull request rather than as a pentest finding.

Evidence
GET /assets/vendor.4f21c8e.js → /** @license lodash 4.17.20 | (c) OpenJS Foundation */ → 61 lodash call sites in the bundle; none are _.template. → no user-controlled object reaches _.merge or _.set on the tested routes.
OWASP: A06 Vulnerable & Outdated Components Fix effort: Trivial — bump the dependency Verified by hand: yes

Controls that held

We test these on every engagement and list them whether or not they produced a finding, because "we checked and it was fine" is part of what you are paying for.

Cardholder data at the origin Held — the PSP iframe is correctly integrated; no PAN reaches your servers or logs, and we could not make the checkout page touch one SQL / command / template injection Held — 340 payloads across every reachable parameter; no injection of any class Admin panel exposure Held — IP-restricted and MFA-enforced; unreachable from an unlisted address even with valid credentials Order IDOR on the authenticated API Held — ownership enforced; a second customer’s order returns 404, not 403 Profile mass assignment Held — only name, phone and address are accepted; role, email and credit are silently ignored Password change Held — requires the current password (401 without it) and invalidates other sessions Invoice download path traversal Held — path normalized and restricted; every .. escape returns 403 User enumeration on login Held — valid and invalid addresses return an identical error and an indistinguishable response time Security headers Held — CSP, HSTS, nosniff and X-Frame-Options all present and correctly scoped File upload handling Held — type and magic-byte checked, stored off-origin under a non-guessable key, served as an attachment

Bottom line: the access-control layer on the authenticated API is well built and the payment integration is done properly. The problems are where business rules are enforced — discount stacking, a surname treated as a secret, a role claim trusted for eight hours, a webhook signature not checked. Those are design decisions, not missing patches, which is why a scanner would have returned none of them.

Severity strata

What each rating means and how fast we think you should move. The same scale on every report, so two of ours are comparable.

Critical No auth needed / full data reachable Immediate — hours High Low-skill exploit, sensitive data This sprint — 48h Medium Needs some context, limited reach 2 weeks Low Chained / minimal impact Backlog Info No direct impact, hygiene Backlog

Re-test

Included in the engagement, run once you report remediation complete. "Accepted as risk" is a real outcome and stays on the record rather than quietly disappearing.

ES-0114-01 Discount stacking Closed duplicate codes rejected, total floored at subtotal ES-0114-02 Order tracking walkable Closed signed lookup token in the confirmation email; form retired ES-0114-03 Stale role claim Closed role re-checked per request; token lifetime cut to 30m ES-0114-04 Unsigned webhook Closed HMAC verified against the raw body, constant-time ES-0114-05 Missing SameSite Closed SameSite=Lax set; CSRF tokens added to both posts ES-0114-06 X-Upstream header Accepted client keeps it for tracing; noted and agreed ES-0114-07 Config disclosure Accepted storefront depends on the flag list; revisit next quarter ES-0114-08 lodash 4.17.20 Closed bumped to 4.17.21, dependency scanning added to CI Get one of these for your app Save this as PDF Severity strata — drag to descend. Critical sits at the bottom because it is what you fix first.
Engagement at a glance
Scope Storefront, checkout and the v1 REST API. Staff admin in scope for authenticated testing; the PSP's hosted card iframe explicitly out. Roles Unauthenticated · guest checkout · customer · staff (fulfilment) · staff (admin) — five perspectives, two customer accounts for tenancy checks Test window Four days Findings 8 — 0 critical · 1 high · 3 medium · 2 low · 2 info Re-test Day 19 — 6 closed, 2 accepted as risk by the client Earthshaker Security

We find the cracks before they do. Automated and human-verified security testing for web applications.

earthshakersecurity.com · contact@earthshakersecurity.com

Earthshaker Security

Work
Services Methodology Pricing Sample report
If you're a…
Founder Shop or small business Developer or CTO
Company
About FAQ Blog Contact Disclaimer Report a vulnerability © 2026 Earthshaker Security Privacy Terms Disclaimer Cookie settings View all pages