Case StudyAugust 1, 202611 min read

How We Built nektr.dev Securely, and What Our Own Audit Found Anyway

We wrote the security rules before the features. We built an allowlist HTML sanitizer and ran it at every write boundary. Then we audited the result and found a Critical stored XSS that walked straight past all of it. Here is the whole thing, including the parts that make us look bad.

At a glance

  • ·Product: nektr, a collaborative AI site builder. You describe a site, Claude generates it as structured JSON, and it publishes to a real domain.
  • ·Stack: Next.js 16 editor, Astro renderer, Yjs over Socket.IO for collaboration, Supabase (Postgres, Auth, Storage), Stripe, Anthropic. Published sites go to Cloudflare Pages.
  • ·Threat model: we render AI-authored HTML on public pages, some of which sit on customers' own domains. A sanitizer gap is not our incident, it is our customer's incident.
  • ·Audit result: 1 Critical, 3 High, 6 Medium, 6 Low or Info. Every finding was re-opened against source in a second pass. All confirmed, no false positives.

1. Why this one was different

Most apps we write about store user data. nektr executes user intent. Someone types a prompt, a model produces a site definition, and that definition becomes real HTML served to the public on a domain the customer may own. The output is not confined to a dashboard behind a login. It is the product.

That changes what a bug costs. In a normal SaaS, an XSS hits your authenticated users. In a site builder, an XSS hits every visitor to every site your customers published, on their domain, with their brand on it. The blast radius is not yours.

There was also history. The product nektr replaced shipped four routes that read and returned page HTML with no authentication check at all. Nobody exploited it, and it is retired now, but it informed the first thing written into this codebase, before any feature work.

2. The rule we wrote before the features

The repository guide has a section titled "Route auth rule (non-negotiable)". It exists so the decision is already made when someone is halfway through a feature at 11pm. Four clauses:

  • Middleware redirects unauthenticated users away from the studio and the AI and project API namespaces. Anything outside that is explicitly the author's job to gate.
  • Every project-scoped route uses one helper, withMembership(role, handler), which checks auth, membership and role in a single call. Rolling your own is not allowed.
  • No route ships Access-Control-Allow-Origin: *. There is exactly one deliberate exception, documented in place with its reasoning.
  • The collaboration server's internal endpoints require a shared secret.

The third clause is worth expanding, because a blanket ban that gets quietly broken is worse than no ban. Published sites live on arbitrary customer domains and have to submit contact forms back to us, so the form endpoint genuinely needs a wildcard origin. It is allowed because it is anonymous, credential-less (browsers refuse to send credentials to a wildcard origin), rate limited per project and IP, accepts submissions only for already-published projects, and never reads a cookie or returns anything sensitive. The rule is not "never", it is "never without that paragraph".

That discipline held up. When the codebase was audited, all 23 project-scoped API routes had the membership gate before any data access, all 33 call sites that use the RLS-bypassing service client had an ownership check in front of them, and nested resource ids were additionally scoped to their parent project, so the classic child-id IDOR did not land anywhere.

3. The sanitizer we were proud of

nektr biases the model toward typed, structured sections. But users ask for things the schema does not cover, so there is an escape hatch: a custom_block section that stores raw HTML. That is the obvious place for XSS to enter, so it got the most attention.

It is allowlist-only. Tags, attributes and class patterns not on the list are stripped rather than filtered. No script, no inline event handlers, no inline style attribute. Link schemes are restricted to https, mailto, fragment and root-relative, which blocks javascript:, data: and protocol-relative URLs. Class names must match a Tailwind-safe character set.

The SVG allowlist is the part worth copying. Presentational SVG is genuinely useful for charts and mockups, so it is permitted, but with specific exclusions:

// Presentational SVG for static charts / product mockups. Deliberately NO
// <foreignObject>, <use>, <image>, <a>-in-svg-with-href, or SMIL
// (<animate>/<set>) - those are script/navigation vectors. Only shapes,
// paint servers, clips, and text.

Two subtler details. DOMPurify can prefix element ids to prevent DOM clobbering, but that breaks SVG gradients and in-page anchors, because fill="url(#grad)" still points at the old id. The fix was to re-point those internal references to the prefixed ids rather than turn the protection off, which is the tempting shortcut. And the sanitizer runs at every write boundary and again at render, so a value that somehow reached storage unsanitized still gets cleaned on the way out.

There is a fuzz harness that throws XSS payloads at it on demand, with no API cost, so it can run as often as anyone likes.

All of that was verified as correct in the audit. And it did not matter, because the Critical came in through a different door.

The finding that justified the whole audit

Brand kits let you pin a font. The font name was accepted as an arbitrary string and written into a <style> block on every published page, unescaped. A font value of Inter; } </style><script>...</script> closes the style element and executes on every visitor to every site using that brand kit.

4. Critical: you sanitize the channel you are thinking about

The colors in the same brand-kit form were validated against a hex pattern. The fonts, sitting directly next to them in the same request handler, had no validation at all. Not a weak pattern. None.

The reason is instructive. Colors look like data that needs a format, so they got one. Fonts look like a name, and names feel harmless. Meanwhile every ounce of XSS attention had gone to the field explicitly labelled "HTML", because that is where HTML comes from. The theme was a styling concern, and styling concerns were not on the threat list.

But a <style> element is a raw-text element. Its contents are not parsed as markup, which means the only thing separating CSS from HTML is the closing tag, and any string that contains one escapes. The build worker sanitized custom-block HTML on the way to publish and never touched the theme, so the payload travelled the entire pipeline untouched and landed in the page.

The generalisable lesson: a sanitizer protects a channel, not an application. We had one hardened channel and one unconsidered one feeding the same output document. When you review your own app, do not ask "is the HTML input sanitized". Ask which strings reach the response, and by which route. Anything interpolated into a style block, an inline script, a JSON island, a meta tag or an attribute is a channel, and each one needs its own answer.

The fix was a shared validator that hard-rejects the breakout characters, applied at both write routes, plus a strip at render time in the Astro component. Two layers, matching the model the custom-block path already used.

5. High: the Postgres RLS clause everyone forgets

This one is worth knowing even if you never build a site builder, because the footgun is in Postgres itself and it is easy to hit.

The policy letting editors update a project looked reasonable. It had a USING clause checking that the caller is an owner or editor of the project. It had no WITH CHECK clause.

Here is the trap. On an UPDATE, when WITH CHECK is absent, Postgres applies the USING expression to the new row as well. That sounds like a safe default and mostly is. But USING only tested membership. It never constrained which columns could change. So an invited editor could run this against PostgREST with their own token:

update projects set owner_id = <self> where id = <projectId>;

They still pass the membership test after the write, because they are still a member. They are now the owner. Every owner-gated check in the product reads projects.owner_id, so they can now remove the real owner, delete the project, or attach a custom domain. Full takeover, reachable by anyone you invited to help with your site.

The fix pins ownership as immutable through that policy, using a security-definer helper so the check reads the committed owner rather than the row being written:

with check (owner_id = project_current_owner(id))

The same missing clause existed on the pages, members, content and assets tables. It was not exploitable there, because none of those tables has a column that grants authority over itself. It got fixed anyway, on all of them, because "not exploitable today" is a property of the current schema, not of the policy.

If you use Supabase RLS, this is the single highest-value thing to go and check right now. Search your migrations for for update policies with no with check, then ask whether any column in that table decides who is allowed to do things.

6. How a convenience fix opened the database

Early on, a migration hit a wall of "permission denied for table" errors from the API. The fix was a blanket grant: everything, on all tables, functions and sequences, to the anon and authenticated roles, plus a default privilege so future tables inherited it. The reasoning was written down and was not unreasonable. RLS is the real access control, and grants only decide whether a role may attempt an operation.

That reasoning is true for tables. It is false for functions, and it made the default fail-open.

Functions have no RLS backstop, and Postgres separately grants EXECUTE to PUBLIC by default. So a set of internal, service-role-only routines became callable by any signed-in user, with arbitrary arguments, over the REST API. The worst was a credit-metering helper that decrements a user's AI prompt balance. Passing a negative amount adds credits. That is a billing bypass in one HTTP request. Two job-queue functions were also exposed, letting any user stall or fail publishes for every tenant on the platform.

The table half bit too. A Stripe events table shipped without RLS enabled and was world-readable through the API, because the default privilege had already granted access and nothing else stood in the way. Its payloads contain customer and subscription identifiers.

It took four migrations to fully unwind one convenience: enable RLS on the events table with deliberately zero policies (service role bypasses RLS, so nothing legitimate breaks), revoke execute from PUBLIC and re-grant only to the service role, stop auto-granting execute on future functions, and finally revoke the table default so a new table is inaccessible until a migration explicitly opens it.

The lesson is not "never use a blanket grant". It is that a default is a decision you make once and then stop seeing. This one was set during a debugging session and silently applied itself to every table and function added for months afterwards. Defaults deserve the same scrutiny as the code you are actually looking at.

7. The other two Highs

An unmetered AI route. The onboarding quickstart ran a full site generation without checking or decrementing the prompt credit balance, while the two other AI routes both did. It also accepted an attacker-controlled prompt, so it was not limited to canned starter text. Any free-tier account could loop it for unlimited model spend on our bill. The fix applied the same gate as the other routes, and added a refund on the failure paths so a rollback does not leave someone charged for a generation they never received. Metering is a security control when someone else pays for the compute.

A single unhandled rejection. The collaboration server keeps live document state in memory and runs as one instance. There was no unhandledRejection or uncaughtException handler anywhere in the process, and Node has crashed on unhandled rejections by default since v15. One stray async throw would drop every active editing session across every project at once. Availability is part of security, and this was the only unauthenticated path to a total outage. Fixed with log-only handlers in both long-running processes, plus a type check on an inbound socket payload that was being passed to a buffer constructor without ever being verified as a string.

8. What we chose not to fix

An audit that ends with everything fixed is usually an audit that avoided the hard calls. Three items were closed without a code change, each on the record.

The asset bucket is public-read. Published static sites embed those image URLs directly, so making the bucket private would break every live site, and signed URLs expire. The keys are UUID-scoped and not enumerable, so this is a capability leak (a leaked URL stays readable) rather than a directory anyone can walk. We decided public bucket with unguessable keys is the correct posture for non-sensitive marketing images, and wrote that down so nobody re-litigates it from scratch.

A Content-Security-Policy on the editor app was deliberately skipped. The editor uses inline styles and scripts and talks to several third-party origins, so a CSP added in a hurry would either break it or be so permissive it proves nothing. Published sites, where the untrusted content actually renders, did get a real CSP with no unsafe-eval and no wildcard script source, so a future sanitizer bypass still cannot pull in a remote script. Protect the surface that renders untrusted content first.

Membership revocation does not reach open sockets. Remove someone mid-session and they keep their existing connection until the 45-minute reauth window closes. Fixing it properly needs a revocation broadcast. It is acknowledged in the code rather than quietly ignored, which is the difference between a known limitation and a bug.

One more worth mentioning because the first fix was wrong. The headers fix originally set X-Frame-Options: DENY, which broke the editor's own device-preview pane, since it legitimately iframes its own preview route. It is now SAMEORIGIN backed by frame-ancestors 'self'. Still no third-party framing, and the product still works. Security fixes are changes, and changes need the same review as features.

9. What actually held

It is worth being specific about what the discipline bought, because the answer is not nothing.

  • Tenant isolation held completely. No cross-tenant read or write was found on any route.
  • The Stripe path held. Signature verified on the raw body before any processing, event ids deduplicated for replay safety, plan and user read from server-written metadata rather than the request body.
  • No SQL injection was reachable. Everything goes through the parameterized query builder, and the stored procedures are static with pinned search paths.
  • The screenshot fetch had a working SSRF guard covering private, loopback and link-local ranges including IPv4-mapped IPv6 forms.
  • Sessions use httpOnly cookies rather than localStorage, and the invite flow uses 122-bit tokens that are expiring, email-bound and single-use.

The pattern is clear in hindsight. Everything that had a written rule and a single choke point held. Everything that depended on someone remembering, in the moment, that a particular field was dangerous, did not.

The takeaway

We wrote the security rules first, enforced them through single helpers, built a serious sanitizer, and still shipped a Critical stored XSS, a full project takeover, a billing bypass and a one-throw outage into a pre-launch codebase. None of them needed anything more than a normal account to reach.

That is not a story about being careless. It is the normal outcome of building software, and the reason to say it out loud is that the alternative version of this post, the one where a careful team gets it right first time, is not true of anyone. The three defects that mattered all shared one shape: a rule was applied thoroughly to the channel someone was thinking about, and not at all to an adjacent one nobody had classified as dangerous. Fonts next to colors. New rows next to existing rows. Functions next to tables.

Reviewing your own work does not fix that, because the same blind spot that wrote the code reviews the code. What helps is a systematic pass that does not care what you intended: enumerate every input, every sink, every default, and check each one on its own terms rather than by whether it felt risky at the time.

That is what vas automates for the classes a scanner can see from outside: exposed keys, missing RLS, storage exposure, headers, auth rate limiting, SSL. The findings above that a scanner cannot reach, the RLS policy semantics and the metering gap, are exactly why a human read of the code is still worth paying for on anything that handles money or multi-user access.

Find the ones you can find automatically

Exposed keys, missing RLS, storage exposure, headers and SSL, checked against your live app in a couple of minutes. Start with the classes that do not need a code read.

Free. Results in minutes.

Related Articles