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.
06On this page
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.
| Code | Runs in | Stopped by |
|---|---|---|
| JavaScript, TypeScript | Web Worker, lib/jsWorker.ts | Terminated after 5 s by default |
| Python | Web Worker, lib/pyodideWorker.ts | Terminated after 20 s |
| C, C++, Ruby, PHP, Lua | Web Worker, lib/scriptWorker.ts | Terminated after 20 s |
| HTML, CSS and page scripts | iframe with sandbox="allow-scripts allow-modals allow-forms" | Replaced on the next run |
| React component exercises | iframe with sandbox="allow-scripts" | Removed after the run |
| SQL | sql.js on the main thread | Nothing |
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 links are hostile input
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:
| Header | Set to | Stops |
|---|---|---|
Content-Security-Policy | See below | Injected scripts, and talking to other origins |
X-Content-Type-Options | nosniff | A response being run as a script because it looks like one |
X-Frame-Options | DENY | Clickjacking |
Referrer-Policy | strict-origin-when-cross-origin | Leaking full paths to other sites |
Permissions-Policy | camera and microphone off, geolocation off, interest-cohort off | Embedded 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-uriandform-actionare'self'.script-srcis'self', Vercel's analytics host and jsDelivr.connect-srcis'self', jsDelivr and the Sentry host if there is one.frame-srcallows'self'andblob:.object-srcandframe-ancestorsare'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.
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
| Route | Limit per IP | Input checks |
|---|---|---|
/api/tts | 40 a minute | Text 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/joke | 20 a minute | No input; a fixed joke if the upstream fails |
/api/client-error | 20 a minute | 413 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.
Finished reading?
0 of 26 chapters read in this series