Engineering

We pen-tested a vibe-coded rent map. Here's the stored XSS we found.

| 11 min read
bengaluru.rent map with thousands of anonymous rent pins across Bangalore

A founder shipped bengaluru.rent, an anonymous Bangalore rent-map with 11,000+ listings. Real product, real users, useful data. It also shipped with a stored XSS any anonymous visitor could plant, and rate limits the attacker's browser fully controls. This post walks through what we found, how the team fixed it in a day, and the security patterns vibe coding tools don't teach.

A friend posted bengaluru.rent in a group chat. Dark map, pins across Bangalore, a number on each pin: that many flats, that much rent. It solves a problem every renter in this city knows. Landlords quote a price, you have no way to check it, and you end up paying what they ask. Pin once, see what everyone else pays nearby, you've got leverage.

The founder built it solo, mostly with AI tools, and pushed it live. That's the right instinct. Ship fast, learn fast, let the market decide. The site works. People use it. The data is real.

With the founder's authorization, we ran a passive pen-test on the public surface. What we found is what happens when you ship before an engineer with adversarial instincts looks at the code. The bugs aren't exotic. They're the ones that separate a prototype from production.

The stack, in 30 seconds

The site is a single static HTML page on Netlify, about 356 KB of inline JavaScript. Supabase sits behind it, called directly from the browser with the anon JWT baked into the page source. No login. Every visitor is anonymous. The anon role and row-level security are the whole security model.

This is a standard Supabase pattern. The anon key is public by design. The risk doesn't live in anyone seeing it. The risk lives in what the anon role can call, and whether those calls trust anything the client sends.

The stored XSS: one pin, every visitor gets owned

Here's the code path that broke the site. When a visitor creates a pin, the form sends society and feedback strings straight to a Supabase RPC. No sanitization, no length cap. When any other visitor clicks that pin, the detail panel renders those fields like this:

if (pin.society) meta.push(`...Society: ${pin.society}</span>`);
if (pin.feedback) meta.push(`..."${pin.feedback}"</span>`);
document.getElementById('detail-meta').innerHTML = meta.join('');

That's the whole bug. A template literal interpolates DB strings into an HTML string, then assigns it with innerHTML. Any attacker who submits a pin with <img src=x onerror="..."> in the society field runs JavaScript in every subsequent visitor's browser.

We proved it live. We submitted one pin with a payload that changed document.title, logged to the console, and rendered a rainbow marquee in the detail panel. Then we opened the pin in a clean browser. The payload fired.

bengaluru.rent pin detail panel executing attacker-supplied HTML: rainbow marquee, hijacked tab title, and DevTools console showing the injected JavaScript running

Before the fix: the tab title reads "PWNED BY SAVI", the pin detail panel renders an attacker-supplied marquee and link, and DevTools confirms the JavaScript ran from the feedback field. Every visitor who clicked this pin executed the same payload.

On a no-login site, stored XSS is worse than it sounds. An attacker doesn't need to phish anyone. They pin once, and every visitor who clicks that pin runs the payload in their own browser. With the Supabase anon key sitting in the DOM, the attacker's JavaScript can call every RPC the anon role is allowed to call, with the victim's session, from the victim's IP. Fake "confirm your email" overlays. Silent pin submissions. Stolen email addresses from every form on the page. All automatic.

The founder's team shipped the fix in a day. They added an escapeHtml() helper and wrapped every DB string before it touches innerHTML. We verified on prod with the same payload. The marquee now renders as literal text, not a DOM node. Clean fix.

Same pin after the fix: the payload renders as escaped text instead of executing

After the fix: identical payload, same code path, same database row. The browser now treats the HTML as text.

Rate limits the browser controls are not rate limits

The site has anti-abuse checks. Every time you submit a pin, the frontend calls check_ip_ban, check_veto_ip, and a nearby-pin dedup check. Good instinct. Flawed execution.

The IP these checks use comes from api.ipify.org, a third-party IP-echo service the browser calls on page load. The result gets stored in a JavaScript variable, then passed as an argument to the RPCs. A second identifier, the device ID, is a crypto.randomUUID() stored in localStorage.

Both values live in the attacker's browser. Open DevTools, read the network tab, pass any IP string and any UUID you want. Clear localStorage and you're a fresh device. Incognito and you're a fresh device. Rotate both per request and every rate limit the server "enforces" evaporates.

We wrote a 20-pin bash script that rotates device ID and spoofed IP per request to prove the spoofing model. The server actually caught it with a different check: inet_client_addr() in the backing RPC uses the real PostgreSQL connection IP, not the client-supplied value. The 20-pin run got zero through. The lesson isn't "the rate limit works." It's "the rate limit works for one of the two checks, and the other checks are cosmetic."

The fix is straightforward. Derive the caller's IP server-side using inet_client_addr(). Stop accepting p_ip from the client. Accept that fully-anonymous abuse is a losing battle and layer IP caps with Cloudflare Turnstile or hCaptcha after N submissions per hour.

The supply chain tag you forgot to pin

One line in the page source:

<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.min.js"></script>

Two problems. The @2 is a floating major version, so any new 2.x release jsdelivr mirrors runs in your users' browsers the next page load, with zero review. And there's no integrity="sha384-..." attribute. If the CDN gets compromised, if a maintainer account gets phished, if a corp proxy MITMs the connection, the tampered script loads silently. It's been done before: ua-parser-js in 2021, node-ipc, event-stream.

Five-minute fix. Pin an exact version, generate the SHA-384, add the integrity attribute:

curl -sL https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.45.4/dist/umd/supabase.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.45.4/dist/umd/supabase.min.js"
        integrity="sha384-PASTE_HASH_FROM_ABOVE"
        crossorigin="anonymous"></script>

The browser now refuses to run the script if a single byte has changed.

The other findings, in a table

Finding Why it matters Fix
No CSP, no security headers Only HSTS was set. A strict `script-src 'self'` kills injected XSS even when escaping misses a spot. Add headers in `netlify.toml`: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy.
13-decimal GPS in public API Sub-millimeter precision pinpoints the exact unit. "Pin anonymously" promise breaks if a renter pins their own flat. Round `pins_public.lat`/`lng` to 3-4 decimals (100-10 meters) and add random jitter of 50-150m per pin.
`contact_creator` and `subscribe_email` unthrottled Anonymous spam-the-operator vector and email-bomb amplifier. GDPR exposure without double-opt-in. Server-side IP throttle, Cloudflare Turnstile, double-opt-in on email subscribes.
`message_board.innerHTML` on raw DB text Anon can't write the table today. One RLS edit, one leaked service-role key, and it's instant XSS for everyone. Render `message` as text. Move click handlers into site-owned templates.
No SPF, no DMARC on the domain Anyone can spoof `@bengaluru.rent` addresses, even if the site never sends mail. Add DMARC `p=reject` and SPF `-all` TXT records.

What vibe coding tools don't teach

Every bug on this site comes from the same pattern: the frontend trusts a value that the attacker controls. Template literals interpolate user text into HTML. Rate-limit checks accept the client's own statement about its IP. A floating CDN version trusts whatever bytes arrive today. A public view ships GPS coordinates with sub-millimeter precision because nobody asked "who reads this, and what can they do with it?"

Vibe coding tools handle the happy path. You describe a feature, they generate working code, you move on. The tools produce exactly what you asked for. You asked for "a form that submits a pin." You got one. You didn't ask "make sure the society field can't contain HTML that runs in other visitors' browsers," so the generated code doesn't guard against it. AI-generated code carries 2.74x more security vulnerabilities than human-written code, and the gap shows up in exactly these places: input handling, authentication, output encoding, trust boundaries.

A senior engineer writes the pin form and flags four things before finishing: society and feedback need length caps and escaping, the nearby-pin check needs to run server-side, the anon RPC needs `inet_client_addr()`, and the public view needs coordinate rounding. Same feature, same afternoon. The difference is the adversarial mental model: every field is attacker-controlled until proven otherwise, and "proven otherwise" means the server, not the client, decides.

If you're serious about shipping to real people

Shipping a prototype on Lovable or Bolt to validate an idea is smart. Shipping a live product with real users, real data, and a public URL is a different problem. Once people trust you with their information, security stops being optional. It stops being "I'll harden it later." Later is when the XSS has been in your database for three months and you don't know which rows are poisoned.

At Savi we build production apps the way this one needed to be built from day one. Senior engineers (1-2 per project) who own the full stack, direct access to the person writing the code, fixed-price quotes after a 30-minute discovery call. We use Cursor and Claude Code daily; we also know what to review in the output. RLS policies, input encoding, rate-limit design, CSP headers, supply-chain pinning: these get written the first time, not patched after a stranger DMs you about a bug.

An MVP like bengaluru.rent, built clean from the start, costs $8,000-$20,000. That's the same ballpark many founders spend on credits, rewrites, and bug-chasing across six months of vibe coding. We've shipped Frootex, DropTaxi, and ZestAMC (now managing $10M+ AUM) on this model. Same stack choices most vibe-coded apps use; Supabase, Netlify, Next.js or Astro. Different outcomes because security, scalability, and infrastructure get designed before the first line of code ships.

The takeaway

The bengaluru.rent team did the hard part: they shipped something people use. They fixed the XSS in a day once they saw the code path. That's the right response and it speaks well of the founder's engineering instinct. This post exists because the pattern is everywhere. Every vibe-coded product shipping right now has some version of these bugs. Most won't get caught until a stranger catches them for you.

If you're building for real users, get an adversarial read on your code before the first stranger clicks around. Either your own senior engineer or an outside review. The bug you find in staging costs an afternoon. The bug a hacker finds in production costs your reputation.

Frequently asked questions

Is it safe to ship a site built on Supabase with the anon key in the frontend?

Yes, if row-level security is tight and every RPC validates its own inputs server-side. The anon JWT is public by design. The risk sits in what it can call, not that it's visible. If your RLS policies and RPCs trust any client-supplied value (IP, device ID, user ID), attackers bypass every check you wrote.

What's a stored XSS and why is it worse than reflected XSS?

Stored XSS saves attacker-controlled HTML in your database. Every visitor who loads that record executes the payload in their own browser. Reflected XSS needs a crafted URL shared per victim. Stored XSS runs automatically for everyone who opens the page.

How do I stop stored XSS in a vibe-coded frontend?

Replace `.innerHTML = userText` with `.textContent = userText` everywhere DB strings reach the DOM. Add a Content Security Policy with `script-src 'self'`; it blocks injected inline scripts even if you miss an escape. Validate and length-cap inputs server-side inside the RPC that writes them.

Why isn't client-side rate limiting enough?

Client code runs on the attacker's machine. They read your JavaScript, see you pass `p_ip` and `p_device_id` as parameters, and send whatever values they want. Rate limits have to run server-side using trusted sources like PostgreSQL's `inet_client_addr()` and the session's real connection.

How much does a secure production build cost versus rebuilding after a breach?

A senior-engineered MVP with proper auth, RLS, rate limits, and security headers costs $8,000-$20,000. Cleaning up after a public breach costs legal fees, user trust, and often a full rewrite on top of that. The engineering work you skip on day one gets paid back with interest on day one hundred.

Related reading

Want a second pair of eyes on your code?

We take vibe-coded sites to production, or build secure MVPs from scratch for $8-20K. 30-minute call, fixed-price quote, no pressure.

Talk to our team

Get in touch

Start a conversation

Tell us about your project. We'll respond within 24 hours with a clear plan, estimated timeline, and pricing range.

Based in

UAE & India