Skip to content
Mocky/Docs v0.2
Reference

AUDIT 2026 07

82 min read

Mocky action plan — multi-agent audit, synthesis and roadmap

Why it works this way

An audit that merely lists defects never turns into work: you have to know where to start. So the document is organised into batches — one batch = one theme, one costed effort, one declared regression risk — because that is the unit you can ship, test, and abandon wholesale if it goes wrong. Every finding carries its file:line path so that verification does not depend on how much trust you place in the report.

Basis: 5 dimension audits + adversarial verification of the security findings. Every finding below has been re-read in the code (paths and lines verified). No file was modified.


0. Verdict on one page#

Why it works this way

A report of sixty findings becomes unreadable if everything in it looks equally serious. This page first settles what works, then names the three problems that govern all the others, and explicitly files the rest under "cosmetic": the hierarchy is the deliverable, not the list. It opens with the successes because an audit that lists nothing but defects loses all sense of what must on no account be broken while fixing them.

Mocky does what it promises: a genuine infinite canvas, streaming generation, Interact mode, links + Demo, 17 presets, snip annotations, runnable Vite ZIP export, accounts + SSO, a complete server-side Muse pipeline. The build is green (tsc --noEmit = 0, 398 tests in 3.3 s), the typing is strict (strict + noUnused*, zero @ts-ignore), and the authentication foundation is sound (scrypt + salt, timingSafeEqual, 32-byte tokens, HS256 verified in constant time with iss/aud/exp/anti-replay jti).

The three real problems are not where you would expect them:

#ProblemWhy this is the real issue
1The persistence layer loses worksrc/lib/sync.ts (86 lines, zero tests) and src/lib/project.ts contain 4 reproducible paths to losing projects. You are the user here: this is risk no. 1 for you personally, ahead of any security question.
2The whole expensive backend surface is open with no authentication/__provider, /api/images/*, /api/muse/dossier, /api/text/vision: 4 mounts with no guard. Ever since the admin can set an LLM key on the server side, the instance is a free LLM proxy plus image generator. Severity conditional on network exposure (see §2).
3There is no design system at alltailwind.config.js → theme: { extend: {} } (verified: empty). The 3 themes are 96 manual overrides of Tailwind utilities in src/index.css:77-203, while the components use 109 distinct colour classes across 595 occurrences. ~76% of the coloured surface is not themed. No redesign is possible without deleting that block first.

And a fourth, on the product side: the Muse dossier is never persisted. The README's headline feature #2 is one-shot — from the very first edit to a screen, the art direction is gone.

What is cosmetic, and which I own as such (do not prioritise): public/ copied needlessly into the Docker image, react/react-dom in dependencies, the missing allow="" attribute on the iframe (not exploitable as things stand, verified), the scrypt password policy, the HSTS/CSP headers on the main document. These are finishing touches, not batches.


1. BATCH 0 — Stop losing work (BUG — absolute priority)#

Why it works this way

The two batches marked "RISK" that follow presuppose a third party: an attacker, a network or a browser has to play along before any damage occurs. This one describes a loss already under way, borne by the person using the tool with nobody else involved, and data loss is the only damage no later fix can undo. Hence the absolute priority, independent of any question of network exposure.

This is the only batch where you are the victim, not some hypothetical attacker. Four independent paths, all inside 450 untested lines.

0.1 reconcileOnLogin overwrites local with the server without comparing dates (critical)#

Why it works this way

As soon as the same data exists in two copies — the browser and the server — reconciliation needs a criterion to settle between them; without one, it guesses. The implicit rule here is "the server wins", which destroys the local copy every time the local copy is the more recent one, that is, every time a tab closed before the deferred upload could leave. Yet the timestamp needed to arbitrate is already written by the server: it is the client's TypeScript type that fails to declare it, so the field is silently dropped on read.

src/lib/sync.ts:72-86 — verified: hasServerProjects is true as soon as the server blob is neither null nor '[]', then localStorage.setItem(PROJECTS_KEY, server.projects) with no arbitration at all. The Project.updatedAt field exists (src/lib/project.ts:52), the server already writes updatedAt: Date.now() (server/index.js:608), but interface ServerData (src/lib/api.ts:24-27) does not even declare it — it is thrown away by the client.

Reproducible scenario: I generate a screen, I close the tab within the second. The localStorage flush goes through (project.ts:89, beforeunload), the 800 ms server push (sync.ts:38) does not. I reopen: App.tsx:49 calls reconcileOnLogin, the server returns the earlier blob, localStorage is overwritten, changed is true, App.tsx:50 runs window.location.reload(). The screen is gone, without a word.

Fix (src/lib/api.ts, src/lib/sync.ts, server/index.js:598-602):

  1. Add updatedAt?: number to ServerData and stop discarding it on the server side.
  2. In reconcileOnLogin, compute localMax = max(p.updatedAt) and adopt the server copy only if it is strictly more recent.
  3. Where both sides have diverged, merge by project.id keeping the more recent one — never delete a local project that is absent from the server.
  4. navigator.sendBeacon('/api/data', blob) in the beforeunload of project.ts:89 to close the 800 ms window.

0.2 pushNow swallows writes made during an in-flight push (critical)#

Why it works this way

Merging several upload requests into one is correct: there is no point writing the same content five times. But merging is only safe if the upload re-reads the data at the moment it leaves; here the read happens once, before the retry loop, so everything written during the fifteen seconds of retries falls outside the snapshot that gets sent. Nothing records "something happened in the meantime", and that is exactly the one piece of information that would be needed to fire another upload.

src/lib/sync.ts:36 — verified: if (pending) return pending // already in flight — coalesce. And doPushWithRetry (sync.ts:44-45) reads localStorage once only, before the retry loop, then retries 5 times with 1+2+4+8 s backoff ≈ 15 s. During that window, every scheduleSync() returns immediately, and at the end pending = null without any dirty flag re-triggering anything.

Combined with 0.1, the loop closes: the server keeps a stale version, then forces it back onto the local copy at the next start-up.

Fix (~10 lines in src/lib/sync.ts): add let dirty = false; have scheduleSync() set it; move the localStorage read inside the while loop; in the finally of pushNow, after pending = null, do if (dirty) { dirty = false; return pushNow() }.

0.3 A sync failure is 100% silent (major)#

Why it works this way

An async function launched from a setTimeout no longer has a caller: nobody is awaiting its result, so its failure surfaces nowhere, except as an unhandled promise rejection. The channel meant to display it already exists in the module, but no component imports it, so thirty seconds of unsuccessful attempts look, from the user's side, exactly like a successful save. A silent failure is more dangerous than a noisy one, because it prevents the only useful reaction: redo the work, or copy it somewhere else.

sync.ts:38: window.setTimeout(pushNow, 800) — pushNow is async and throws new Error('Sync failed after retries') (sync.ts:58) with no .catch() at all → unhandled promise rejection. The syncStatus() function (sync.ts:29-31) was written for precisely this (the comment on line 24 says "lets the UI show a syncing… / sync failed indicator") but it is imported nowhere.

Fix: an explicit .catch() at the scheduling point; turn syncStatus into a small observable store (subscribe(cb) + 'idle'|'syncing'|'failed') rendered in the header of src/App.tsx:180-201 next to the account button, with a "Retry" button; warn on beforeunload when the state is 'failed'.

0.4 localStorage.setItem without try/catch: QuotaExceededError stops everything, permanently (major)#

Why it works this way

A browser's local storage has a finite size and refuses writes beyond it by throwing an exception. If nobody catches it, nothing further in the function runs: neither the reset of the pending queue nor the trigger for the server sync — so a single refused write severs both save paths at once, for the whole session. The threshold arrives all the sooner because each screen keeps two complete versions of its source code, doubling the footprint in exchange for a single level of undo.

src/lib/project.ts:73 — flushProjectsNow() performs the setItem unprotected, called from a setTimeout (l.85) and from beforeunload (l.89): nobody catches. If it throws, pendingProjects = null (l.74) is never reached and neither is scheduleSync() (l.79). Local saving AND server sync both stop, silently. Your actual blob already weighs 209,684 bytes, and every screen stores code AND previousCode (project.ts:26-28) — the footprint is nearly doubled for nothing.

Fix: try/catch + reporting through the syncStatus channel with an actionable message; remove previousCode from what is persisted (keep it in an in-memory Map inside ProjectView — it is only used by onRevertScreen, ProjectView.tsx:292-301) → footprint roughly halved for zero functional regression.

0.5 Two Mocky tabs overwrite each other (major)#

Why it works this way

Local storage is shared between all tabs of the same site, but it is not watched: each tab reads the project list once when it opens, keeps it in memory, then rewrites the entire list on every save. A tab that was never told about an addition made elsewhere therefore deletes it by writing its own version, then pushes that truncated version to the server. The browser does emit an event made for exactly this, addressed to other tabs only; it would be enough to listen for it, and nothing does.

Verified by grepping all of src/: not one occurrence of addEventListener('storage', BroadcastChannel, visibilitychange or pagehide. Each tab loads the projects once on mount (project.ts:220) then rewrites the whole array on every flush (l.73). Tab A generates a screen; tab B moves a frame → its flush rewrites everything without A's screen, then pushes that truncated array to the server. Same pattern for DESIGN.md (src/lib/design.ts:28-29).

Fix (~15 lines): a useEffect in useProjects (project.ts:219-224) listening to window.addEventListener('storage', …) — the event is only emitted towards other tabs, which is exactly the behaviour wanted. Same in design.ts.

0.6 reconcileOnLogin runs twice per sign-in (major)#

Why it works this way

One and the same responsibility — reconciling local and server after sign-in — is written in two places, in the auth modal and in the root component. Two concurrent runs of the same exchange read and rewrite the same data without coordinating: the outcome then depends on the order in which the network responses arrive, and two page reloads race each other. The problem is not the cost of duplicate requests, it is that saving no longer has a single owner.

Verified: src/components/AuthModal.tsx:47-51 does enableSync(true) + await reconcileOnLogin() + a conditional reload, and src/App.tsx:276-284 (onSignedIn) does exactly the same thing again. Two concurrent GET /api/data, potentially two PUT, two reload paths racing.

Fix: delete the corresponding lines from AuthModal.tsx — the modal authenticates and calls onSignedIn, full stop. App.tsx remains the sole owner of reconciliation.

0.7 Safety net#

Why it works this way

The preceding fixes alter the path that writes your data, that is, the part of the code where a regression does not show up straight away and cannot be recovered from. So the tests are written first, against the exact scenarios that break, so that the fix is verifiable rather than merely plausible. To that is added a React error boundary, because an exception during render tears down the whole tree: without one, a single faulty line in a large component yields a blank page, with no message and no way back.

  • src/lib/sync.test.ts (to be created): fake api + fake localStorage, 4 cases — server more recent, local more recent, writes during an in-flight push, retries exhausted.
  • src/lib/project.test.ts (to be created): 300 ms debounce, flush on beforeunload, setItem throwing, storage event.
  • src/components/ErrorBoundary.tsx (to be created, ~40 lines): src/main.tsx:10 mounts with no boundary at all (the project's only one is injected inside the iframe, Preview.tsx:167). A render exception in ProjectView.tsx (1,624 lines, full of unguarded screens.find(...), e.g. l.940 and l.410) = a mute blank page with no way back. Two levels: around (a "Reload" button) and around in App.tsx:220-231 (a "Back to projects" button → goHome()), so that one corrupted project does not take the others with it.

Batch 0 effort: 2–3 days. Regression risk: medium (we are touching the persistence path — hence tests first).


2. BATCH 1 — Lock down the server (RISK)#

Why it works this way

The severity of this entire batch depends on a variable that is not in the code: is the instance reachable beyond its owner's machine. The same defect rates as "fix one day" on an isolated workstation and "fix before anything else" behind a public domain name, hence a section that opens with the decision rather than with the findings. It is placed first because a single line of configuration, the address the port is published on, tips you from one case to the other.

A prior decision, to be taken before opening this batch. This whole section assumes the instance is reachable beyond your machine. docker-compose.yml:16-17 publishes "8787:8787" on all interfaces, and server/index.js:642 calls app.listen(PORT) with no bind address (so 0.0.0.0).

  • If Mocky stays on your PC / your trusted LAN → this batch drops to priority 3, after the design. Do 1.1 and 1.2 anyway (30 minutes).
  • If Mocky sits behind Caddy/Nginx and is reachable from the internet → this batch comes before everything else, batch 0 included. A 2-minute fix in both cases: ports: - "127.0.0.1:8787:8787" in docker-compose.yml:16-17.

1.1 Four expensive routes with no authentication whatsoever (critical)#

Why it works this way

In Express, protecting a route is a matter of ordering: an identity check only applies to what is mounted after it. These four mounts come before the authentication function is even defined: this is therefore not a badly written check, it is the absence of one. What changes the nature of the problem, now that the administrator can place a model key on the server, is that these routes no longer merely give access to data: they spend the host's money, write files and delete them.

Middleware chain verified in server/index.js: app created at l.238, cookieParser l.239, security headers l.242-248 (no auth), then the mounts. currentUser is defined at l.286 and requireAdmin at l.306 — so afterwards, and they are never applied to the routes below:

RouteLineWhat an anonymous user can do
/__providerindex.js:279Unlimited inference on the admin's LLM key. Worse for kind: 'ollama': dialect.js:127-137 builds url = base + subpath from req.originalUrl and provider-proxy.js:174 preserves the method → DELETE /__provider/api/delete with {"name":"..."} (the name field escapes the model rewrite, l.150) deletes a model on the admin's Ollama.
/api/text/visionindex.js:492Read SSRF. Verified line by line: when no admin provider is configured (the default state), baseUrl comes from the x-provider-base header (l.500) and assertSafeTarget is never called — unlike provider-proxy.js:129-138 and muse/llm.js:40. vision.js:104-113 returns up to 400 characters of the internal response body. A full LAN sweep plus 169.254.169.254 from the internet, with no account.
/api/images/*index.js:625GET /library exposes every prompt (= client briefs) and other people's projects; library.zip exfiltrates the lot; DELETE /:hash performs an fs.rmSync (library.js:219), with no trash. And the hashes are not a secret: GET /library lists them. A self-contained destruction chain.
/api/muse/dossierindex.js:613Spends the admin provider's tokens (muse/routes.js:52-58, trusted: true). With useFetch:true, launches Chromium.

Fix (~30 lines, server/index.js):

// before line 279function requireUser(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({error:'auth'}); req.user = u; next() }
  • /__provider: refuse with 401 if textConfig.target(profileFromRequest(req)) exists and currentUser(req) is null (browser-key mode can stay anonymous: it costs the host nothing).
  • app.use('/api/images', requireUser, images.router) and app.use('/api', requireUser, createMuseRouter(...)).
  • DELETE /api/images/:hash → requireAdmin (or the owner, see 1.4).
  • /api/text/vision → requireUser and assertSafeTarget(baseUrl + '/api/chat') inside a try/catch before building target (index.js:500-505).
  • Restrict the /__provider sub-path to an allow-list (/api/chat, /api/tags) in provider-proxy.js:121-124.

1.2 The SSRF guard can be bypassed (major)#

Why it works this way

Stopping a server from fetching an internal address on a third party's behalf means recognising every way of writing the same address — and there are many, while the guard here compares strings. An IPv4 address dressed up as IPv6, or 0.0.0.0, resembles none of the patterns tested and yet genuinely reaches the local machine. Above all, the guard checks the address requested and not the address finally reached: since redirects are followed by default, an allowed target need only answer "look over there" for the check to collapse in a single step.

server/provider-proxy.js:35-56. Verified by running the exported function:

  • http://[::ffff:127.0.0.1]/... passes — new URL(...).hostname is [::ffff:7f00:1], and line 54 only tests ::1/[::1]/fe80/fc/fd; a string starting with [ matches nothing.
  • http://0.0.0.0/... passes — the IPv4 regex (l.41) matches but a === 0 falls into no branch of isPrivate (l.44-49).
  • Both genuinely reach the loopback (fetch tested).
  • The fetch at provider-proxy.js:173-177 has no redirect option → undici follows 3xx by default. A 302 to http://169.254.169.254/latest/meta-data/… is followed and the body returned (provider-proxy.js:246-249).

Conversely 127.1, 2130706433, 0177.0.0.1 are correctly blocked (normalisation by URL). The same guard is shared by server/muse/fetch/fetcher.js:79; and server/muse/fetch/robots.js:97 explicitly sets redirect: 'follow'.

Fix (server/provider-proxy.js): redirect: 'manual' + refuse/re-validate any 3xx; strip IPv6 brackets and reject IPv4-mapped addresses (::ffff:*), ::, 0.0.0.0/8, 100.64.0.0/10; resolve via dns.lookup({all:true}) and re-check every address before the call. Apply the same to robots.js:97.

1.3 /__provider buffers a body with no limit (major)#

Why it works this way

Accumulating a request body in memory without a bound amounts to letting the client decide how much memory the server consumes. The 25 MB limit that exists elsewhere does not apply here: it is mounted further down the chain and on a different prefix, and this handler never hands over to what follows. The point that turns slowness into an outage is that the final assembly throws its exception inside an event listener, outside any promise chain: an exception nobody catches brings down the entire Node process.

provider-proxy.js:76-83: readRawBody accumulates everything then Buffer.concat, with no bound. The express.json({limit:'25mb'}) at index.js:283 is mounted after (l.279) and only on /api; on top of that the /__provider handler is terminal (it never calls next()). An aggravating detail not caught initially: past buffer.constants.MAX_LENGTH, Buffer.concat throws inside the req.on('end') listener (l.80), outside any promise chain → uncaught exception, process crash (there is no uncaughtException handler anywhere in server/).

Fix: a byte counter + req.destroy() past 25 MB → 413; an AbortController with a deadline on the upstream fetch (l.173).

1.4 A global image library with no owner (major)#

Why it works this way

A multi-account application can only isolate what its data model knows how to attribute. Here an image's record stores the prompt, the seed, the dimensions and the projects, but no user identifier: there is therefore nothing to filter on, and no check placed at route level makes up for that absence without first changing the data being written. What is exposed is not trivial, since an image's prompt contains the actual brief, that is, what the client asked for.

server/images/library.js:151-163: the stored metadata is {hash, prompt, negative, provider, seed, width, height, createdAt, tags, projects, favorite} — no user field. library.list() (l.195-207) filters only on query/project/favorites/slotType, and library.zip() (l.261-290) re-injects prompt/negative/seed into manifest.json. The prompts contain the actual brief (src/lib/muse.ts:240-250 posts prompt: slot.prompt || slot.subject with project: project.id).

Fix: owner: userId in the metadata, filtering in list() except for admins, refusal of remove/toggleFavorite if meta.owner !== req.user.id. Requires threading the user from routes.js down to library.js.

1.5 Rate limiting is inoperative behind a reverse proxy (medium — but one line)#

Why it works this way

A "per IP address" quota presupposes knowing the visitor's address; behind a reverse proxy, the address the server sees is the proxy's, the same one for everybody. Express knows how to read the header the proxy adds to restore the original address, but only if it is explicitly allowed to — refusing to believe it by default is the right choice, provided you then configure it. Without that the counter becomes instance-wide, and the protection turns against you: a handful of attempts is enough to stop every legitimate account from signing in.

server/index.js:256: req.ip || req.socket?.remoteAddress, and no app.set('trust proxy', …) anywhere (grep confirmed). Yet README.md:187-199 documents Nginx with proxy_pass. Result: req.ip = 127.0.0.1 for everybody → the quota of 8 attempts/minute is instance-wide. 9 /api/login requests per minute and nobody can sign in any more.

Fix: app.set('trust proxy', process.env.TRUST_PROXY || false) before cookieParser (l.238-239), document it in .env.example and README.md:108-115, derive secure from req.secure rather than from NODE_ENV (l.301). Extend authRateLimit to /api/images/generate, /api/muse/dossier, /__provider.

1.6 Sessions with no server-side expiry (medium — severity revised down)#

Why it works this way

The lifetime written into a cookie is an instruction given to the browser, not a rule enforced by the server: the only binding expiry is the one checked at the moment the token is re-read, and it does not exist. The severity stays moderate because the cookie is marked httpOnly, so invisible to the page's JavaScript: stealing it already presupposes access to the files or to the machine. That is precisely why file permissions appear in the same finding — tokens and API keys readable by any local user make such access trivial the moment it exists.

server/index.js:296 writes sess.t, currentUser (l.286-292) never reads it; the maxAge (l.302) is a mere instruction to the browser; there is no pruning anywhere (whereas the SSO JTIs are properly pruned at l.155-156).

To be fixed, but this is not a way in: the cookie is httpOnly (l.299), so an XSS cannot exfiltrate it — you need file/backup/machine access. And two initial claims are false: the admin→user demotion takes effect immediately (currentUser re-reads users.json on every request, l.291), and there is no password-change route at all.

Fix: in currentUser, reject and delete any session where Date.now() - sess.t > MAX_AGE; a sliding TTL; a purge at start-up modelled on consumeJti (l.151-163); POST /api/logout-all (the loop already exists at l.592). And pass { mode: 0o600 } to the writeFileSync calls in server/index.js:76, text/config.js:212, images/config.js:194 (0644 today: API keys and session tokens in the clear, readable by any local user).

1.7 CSRF: SSO login-CSRF only (medium — severity corrected)#

Why it works this way

Most requests forged from another site are already blocked here, but by accident: the server accepts JSON only, which a remote form cannot send without triggering a browser preflight that nothing satisfies. What remains is the one case that escapes both barriers, plain GET navigation, which is exactly what SameSite=Lax lets through, and that is how the external authentication callback sets a session. A session set by a third party is not merely stolen access: reconciliation starts immediately and pushes local content into the account the attacker chose.

What is refuted: "an app compromised on *.example.com can forge POST /api/admin/users" — false. express.json accepts only application/json (body-parser sets req.body = {} otherwise); a cross-site form can only send urlencoded/text-plain/multipart → 400; and real JSON triggers a CORS preflight the server never satisfies (there is no cors middleware anywhere in the repository).

What remains real, and serious: app.get('/sso/dashy/callback') (index.js:393) sets the session via setSession (l.423) on a top-level GET navigation — exactly the case SameSite=Lax lets through. The state check is purely client-side (src/lib/sso.ts:81) and never shown: App.tsx:34-39 calls setSsoError(), but ssoError is only rendered inside , which only opens if api.me() returns null (App.tsx:45-46) — and the cookie has just been set. Aggravating factor: App.tsx:49 immediately fires reconcileOnLogin(), so sync.ts:84 pushes your entire localStorage into the attacker's account (or l.80 overwrites your projects with theirs).

Fix: a state cookie set server-side before the redirect to Dashy and checked before setSession (l.423); sameSite: 'strict'; an Origin/Sec-Fetch-Site check against MOCKY_ORIGIN; make MOCKY_ORIGIN mandatory when ssoEnabled — otherwise expectedAudience (l.405-406) falls back to req.get('host'), which neutralises the aud check and opens an open redirect (curl -H 'Host: evil.tld' … → 302 to http://evil.tld/).

Batch 1 effort: 2–3 days (1.1 + 1.2 + 1.3 = 1 day and cover 80% of the risk). Regression risk: low to medium — 1.1 breaks the "frontend only, no account" mode should it ever come back (see batch 2).


3. BATCH 2 — Close the browser sandbox escapes (RISK)#

Why it works this way

Mocky runs code written by a model in your browser: the only thing separating that code from your data is the browser's isolation, and that isolation rests entirely on the exact value of one attribute. The section opens by noting that the preview iframe is correctly configured, precisely so that nobody later "improves" it by adding a flag: each absent flag is a decision, not an oversight. What follows falls into two families: a second, privileged frame that goes around that isolation entirely, and the two things the sandbox attribute does not settle — what the document may send to the network, and who may speak to it.

The main sandbox is correct: src/components/Preview.tsx:425 → sandbox="allow-scripts" without allow-same-origin (verified). Opaque origin: no localStorage, no cookie, no parent DOM, no popup, no top-level navigation, no download, no form. The absent flags are exactly the right ones — on no account add allow-popups or allow-top-navigation.

Three holes go around it.

2.1 capture.ts raises a same-origin iframe (critical)#

Why it works this way

An iframe filled by srcdoc and granted allow-same-origin is no longer isolated: it inherits the application's origin, so the model's code reaches local storage, session cookies and the parent document exactly as Mocky itself would. Everything the preview carefully refuses is granted here for the duration of a capture. What makes the finding critical is not the duration but the trigger: the capture is started by an ordinary product action, a drag in annotation mode, not by some exotic manipulation.

src/lib/capture.ts:152 — verified: iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin') with srcdoc (l.154), which runs the model's code (l.109/114). srcdoc + allow-same-origin ⇒ the iframe inherits Mocky's origin. The comment at l.18-19 owns it ("Fine for a self-hosted tool"). Trigger: ProjectView.tsx:247-268 → annotation mode (l.894-897) — a normal product action, one click.

During the capture, the LLM-generated code can read localStorage['mocky.settings.v1'] (your provider key in the clear, settings.ts:28,53), call fetch('/api/admin/users', {credentials:'same-origin'}) — the mocky_sess cookie is httpOnly but sent automatically — hence create an admin account, overwrite all your projects via PUT /api/data, and rewrite parent.document. The only barrier is the model's goodwill.

Fix: remove allow-same-origin and let html2canvas run inside the null-origin iframe — it is already loaded there (capture.ts:122, /vendor/html2canvas.min.js), and toDataURL() works on an opaque origin as long as the canvas is not tainted. The only real tainter is : add Access-Control-Allow-Origin: * on server/images/routes.js:91-102 and useCORS: true in the html2canvas options (capture.ts:135). Fallback if the canvas stays tainted: serve a /capture.html page from a second port (distinct origin, same machine) and hand the dataURL back by postMessage.

2.2 That same same-origin iframe loads JS from an unpinned CDN (critical)#

Why it works this way

A script tag pointing at a URL with neither version nor fingerprint delegates to a third party — and to whoever controls the DNS between them and you — the right to run code of their choosing, at your origin, indefinitely. That is already awkward in an isolated preview; inside the frame of the previous finding, which shares the application's origin, it is direct access to your keys and your projects. The invariant banning CDNs did exist: its test only looked at the declarative capability registry, never at the tags hard-coded in the document builder, so it went green without checking anything.

src/lib/capture.ts:101-102: https://unpkg.com/@babel/standalone/babel.min.js — no version, no integrity. capture.ts:120 and Preview.tsx:107: https://cdn.tailwindcss.com — same. A CDN compromise (or just DNS/a proxy on your LAN) = JS execution at Mocky's origin.

A documented contradiction: Preview.tsx:22-27 states "never a CDN", and docs/adr/001-muse.md:69 lays down invariant I3 ("No CDN