← All writing

A self-audit of a production SaaS

·4 min read


Soodori is a paid, subscription-based AI product I've been running. I did something I'd been putting off: I sat down for a day and tried to break my own app.

Not a pen test. No scanner. No bug bounty. Just me, the repo, a coffee, and the posture of a bored attacker who wants to know if there's a soft target. The goal wasn't to enumerate every CVE-class bug; it was to walk the attack surface end to end and ask, in each place, "what would I try if I didn't own this?"

I walked away with six findings, organized into four categories: rendering, payment, incentives, data handling. What follows is the mental model for each one — enough to recognize the pattern in your own app, not enough to hand anyone a recipe.

Rendering

1. Rendering AI output as HTML. This is the boring, common one. Chat messages are the model's output concatenated with the user's input. If you pipe that straight into the DOM without a markdown-aware renderer, and you're not disciplined about raw HTML, you're one clever prompt away from executing attacker-chosen JavaScript in another user's session — the same session that holds a billing cookie.

The entire surface here is "don't do it." Use a markdown library that escapes HTML by default, refuse raw HTML in the parsed tree, and constrain link schemes to the ones you actually want. I was surprised how often this pattern shows up in AI-assisted codebases. It reads innocuous — three regex replaces and a dangerously-named innerHTML helper — and the diff looks small. The blast radius is not small.

Payment

2. Non-idempotent webhook handlers. The broader failure mode: webhooks are retried. They're retried on transient errors. They're retried by you on the dashboard when you're debugging. If your handler applies a side effect on each delivery — credit a bonus, snapshot usage, record a payment — every retry replays it.

The fix is a persistent dedupe layer. Record the event id before any side effect. Treat a duplicate as a success-no-op. On handler failure, roll the dedupe record back so the next retry gets a fresh shot. This is a boring pattern worth internalizing once; every future webhook in every future project will want it.

Incentives

3. Referral farming via the signup bonus. If your referral program gives a bonus the moment a new account signs up, you have shipped free money. A single referrer can spin up accounts, enroll them all under their own code, and drain the pool. OAuth doesn't save you — creating Google or regional provider accounts is essentially free.

The shape of the fix is not a smarter fraud detector. It's tighter unit economics. Cap how many enrollments can redeem a single code, and gate the referrer's reward on the referee doing something expensive for an attacker to fake — most obviously, paying. Signup bonus for the referee only; payment bonus for the referrer, one-time per referred user.

4. Stackable one-time rewards. Reviews. Shares. First-payment referral credit. All of these are race-condition targets. Two simultaneous requests both read the flag as "not yet rewarded," both apply the bonus, both credit. Plus one becomes plus two. Repeat in a loop and it becomes plus N.

Make the claim atomic: write the flag and the reward in a single statement the database can serialize, gated by a where-clause that only matches the unclaimed state. If the update matches zero rows, the claim was already made by a concurrent request — return success-no-op and move on. Cancel-and-resubscribe loops are a variant; tie the "already credited" flag to the referred user, not to the payment event.

Data handling

5. Mass-assigned update fields. An API route that forwards a request body's updates object straight into a database write is a time bomb. Even if the schema doesn't expose anything dramatic on its face, foreign key columns are usually sitting right there — primary among them the ones that tie a row to its owner. An authenticated caller can reparent a row to another user's id and quietly take over someone else's data.

The fix is simple and dull: explicit field whitelists on every write path. The cost of forgetting once is large; the cost of doing it every time is a few extra lines.

6. Unauthenticated endpoints that spend money. Any route that calls a paid API — an LLM, an embedding model, an image generator, a third-party enrichment service — and isn't gated by authentication plus a rate limit is a knob an attacker can spin until your bill is interesting. "Followup" / "suggested questions" / "autocomplete" endpoints are especially vulnerable because developers think of them as internal plumbing, not user-facing.

If the endpoint calls a metered service, it needs three things: a session check, a per-user-per-window limit, and a hard cap on how long its inputs can be. Everything else is optional.

What I'd bake in on day one next time

  • A markdown-aware renderer in the template. Raw HTML is not a default.
  • A webhook handler template that logs loudly on signature failure, writes the event id to a dedupe table before any side effect, and unwinds on handler failure.
  • A write-layer helper that takes an allowed-fields list and rejects everything else, applied uniformly across tables that can be mutated through an API.
  • A rate-limiter you can drop on any route in one line.

None of these are clever. They're the boring floor of "production" that's easy to skip when you're shipping fast.

Auditing your own app is cheap. Pretending to be an adversary for a few hours buys you perspective that feature work never will. Do it before the first retention curve, not after the first incident.