How this is builtGroundworkHow this is built
21 / 26 written chapters

A2 · Part 3 · How it holds up

Security: strangers' code and strangers' links

Code runs in workers and sandboxed frames, share links are parsed as hostile, and a policy says who the page may talk to.

8 min read1 diagram6 sections
06On this page
  1. What there is to attack
  2. Where the reader's code runs
  3. Share links are hostile input
  4. The headers and the policy
  5. What the API routes accept
  6. Dependencies

What there is to attack

Most of what a security review looks for is not here. There is no login, so there is no session to steal and no password to reset. There is no database, so there is nothing to inject into. Nothing a reader writes is stored on a server and shown to someone else.

What is left is more interesting than that list suggests:

  • three API endpoints
  • one Content-Security-Policy
  • a playground that runs whatever a reader types, in 11 of its 17 languages
  • two kinds of share link that carry data from one stranger's browser to another's

The last two are where the care went.

Where the reader's code runs

The rule is simple: the site never receives the code. It runs on the reader's machine, inside the browser's own sandbox, and it runs away from the page wherever possible.

CodeRuns inStopped by
JavaScript, TypeScriptWeb Worker, lib/jsWorker.tsTerminated after 5 s by default
PythonWeb Worker, lib/pyodideWorker.tsTerminated after 20 s
C, C++, Ruby, PHP, LuaWeb Worker, lib/scriptWorker.tsTerminated after 20 s
HTML, CSS and page scriptsiframe with sandbox="allow-scripts allow-modals allow-forms"Replaced on the next run
React component exercisesiframe with sandbox="allow-scripts"Removed after the run
SQLsql.js on the main threadNothing

Workers. A worker has no DOM, no localStorage and no cookies. On a timeout the runner does not ask the worker to stop. It calls terminate() and throws the worker away, and the next run makes a fresh one. An endless loop costs the reader a few seconds and nothing else. A worker can still call fetch, and what bounds that is the policy's connect-src, below.

Frames. Neither frame gets allow-same-origin, so each document runs with an opaque origin. A page a reader builds in the playground cannot read the site's storage or touch the parent document. Messages come back through postMessage, and the parent accepts one only if event.source is that frame's own window and the message carries the expected marker. A srcdoc frame also inherits the parent's Content-Security-Policy, so the reader's page cannot load scripts from an origin the site itself could not.

The two gaps. Before handing JavaScript to the worker, the runner builds it with new Function on the main thread to catch syntax errors early. It only constructs the function and never calls it, but that is why the policy allows 'unsafe-eval'. SQL is the other one: lib/sqlRunner.ts runs sql.js on the main thread with no timeout. A runaway recursive query freezes the reader's own tab. It reaches nobody else, but it is the one runtime that is not isolated.

Share link #share= or #board= Parse and sanitise readShare, sanitizeEls The page site origin, CSP applies storage, DOM, cookies Web Workers no DOM, no storage, terminated Sandboxed frames opaque origin, source checked /api/ functions rate-limited, inputs clipped untrusted trusted
Everything to the left of the green box, and everything below it, is treated as hostile. The green box is the only place with access to the reader's storage.

The playground shares as /practice?id=free#share=... and the whiteboard as /whiteboard#board=.... Both compress JSON with the browser's CompressionStream("deflate-raw") and encode it as base64url, in lib/compress.ts. The payload lives after the #, so browsers never send it to a server and nothing is stored anywhere. That also means the site cannot vet a link before a reader opens it. Whoever opens it runs the parser, so the parser assumes the worst.

lib/shareLink.ts accepts only an array of three-string tuples. The language must be one the playground knows. It keeps at most 20 files, cuts names to 60 characters and code to 200,000. Any failure in decompression or parsing returns null, never a half-read project.

sanitizeEls in lib/whiteboard/model.ts does the same for boards, one field at a time:

  • anything that is not an array becomes an empty board
  • the element kind must be on an allow-list
  • ids are strings of at most 64 characters, and duplicates are dropped
  • every number must be finite and is clamped to ±10,000,000
  • stroke width is clamped to 0.5–60, opacity to 0.05–1 and font size to 6–400
  • colours are cut to 40 characters, and dash and arrowhead styles come from allow-lists
  • a line keeps at most 20,000 finite points, and text at most 20,000 characters
  • an image's source must start with data:image/, so a board cannot make the browser fetch a remote URL, which would be a tracking pixel
  • an arrow bound to an id that does not exist has that binding set to null

The same function guards all three ways a board arrives: a share link (limit 2,000 elements), an imported JSON file (5,000) and the board saved in localStorage (50,000). Share links drop images entirely, and the menu says how many were dropped. The whiteboard's model has 17 unit tests.

What a board holds is drawn by React as SVG attributes and text, never as HTML. There are 39 uses of dangerouslySetInnerHTML in the app, and every one is fed from content in the repository. None is fed from a share link, from storage or from anything a reader typed.

The headers and the policy

next.config.ts sets five headers on every path:

HeaderSet toStops
Content-Security-PolicySee belowInjected scripts, and talking to other origins
X-Content-Type-OptionsnosniffA response being run as a script because it looks like one
X-Frame-OptionsDENYClickjacking
Referrer-Policystrict-origin-when-cross-originLeaking full paths to other sites
Permissions-Policycamera and microphone off, geolocation off, interest-cohort offEmbedded content asking for hardware

The policy is built when the config loads, not written out by hand. The runtime origins come from wasmOrigins(), which today is jsDelivr alone. The error-reporting origin comes from sentryOrigin(), and only when a DSN is set.

  • default-src, base-uri and form-action are 'self'.
  • script-src is 'self', Vercel's analytics host and jsDelivr.
  • connect-src is 'self', jsDelivr and the Sentry host if there is one.
  • frame-src allows 'self' and blob:.
  • object-src and frame-ancestors are 'none'.

Two unit tests keep those helpers from ever returning something that is not a URL. A malformed entry would quietly break the whole policy.

Where the policy is weak: script-src includes 'unsafe-inline' and 'unsafe-eval'. The first covers the theme script, which has to run before first paint, and Next's inline scripts. The second covers the playground. Together they mean the policy limits where scripts come from, not whether an injected inline script could run. The defence against injection is that nothing a reader controls is rendered as HTML. The config also sets no Strict-Transport-Security header of its own.

The policy once blocked the site's own analytics

For a while, Vercel Analytics and Speed Insights failed without a word. The policy did not allow the host they load from, so nothing was recorded. A policy that fails closed also fails quietly. This was found by watching what a real browser requested, not by reading the policy.

What the API routes accept

RouteLimit per IPInput checks
/api/tts40 a minuteText at most 2,000 characters (413 if longer); voice from a fixed list; rate clamped to 0.5–2; pitch matched by a pattern; text escaped before it goes into SSML; 20 s maximum duration
/api/joke20 a minuteNo input; a fixed joke if the upstream fails
/api/client-error20 a minute413 above 4,000 bytes; each field cut to 500 characters, the user agent to 200; bad JSON is dropped with a 204

The in-process limiter in lib/rateLimit.ts is a Map keyed by route name and the first address in x-forwarded-for. It sweeps expired entries once it tracks more than 5,000. It lives inside one function instance, so it is a floor, not a wall. The wall is the Vercel Firewall rule on /api/, set in the dashboard and deliberately kept out of this public repository.

Dependencies

.github/dependabot.yml checks npm every week, with at most five open pull requests. It groups Next, React, CodeMirror and the dev tooling so that packages that move together arrive together. It ignores major versions of @types/node, which follows the Node in .nvmrc rather than the newest release. GitHub Actions are checked monthly. vercel.json stops those branches from building preview deployments, and nothing checks them automatically.

The honest part: of 13 Dependabot pull requests so far, 13 were closed and none was merged. npm audit today reports a critical advisory against the installed Next 16.3.0 and a high one against sharp. Why they are stuck, and what it takes to move, is in the health chapter.