Full-app review — 18 August 2026
Reviewed at main @ 6d923a4. Thirteen findings, ordered by whether they can break or
expose a published site. Each entry names the file, the line, what goes wrong,
and the shape of the fix. Fixed entries are marked; everything unmarked still stands.
Report (same content, rendered): https://claude.ai/code/artifact/879bdcb0-e26b-4d29-bc6a-487a4dcf8f4f
Fix first — can break or expose a live site
1. Filenames become script on the published site — FIXED 18 August 2026
main/renderer.js:152 (and 160, 231, 241, 242, 262, 271, 294, 305, 307, 330,
342, 344, 346, 389, 400, 1045, 1127)
Filenames are interpolated into generated HTML with no escaping across the
file-wrapper templates — href, src, alt, title, <title>, <h1>,
<header>, <p>. A file named x" onfocus=alert(1) autofocus q.md produces:
<a class="cheldrop-btn" href="x" onfocus=alert(1) autofocus q.md" download>
The handler then runs for every visitor to the public site. Confirmed by
evaluating toolbarHtml directly.
Reachable without the owner typing the name: site folders are expected to live
in Dropbox and iCloud (that is what CLOUD_PATH_MARKERS in sitefolder.js is
for), and the MCP server lets an agent write files into them.
Fix: esc() already exists at main/renderer.js:598 and is used correctly
by the header/footer/sidebar code at 740–869. Apply it to every interpolation
in the wrapper templates. Start with toolbarHtml (152, 160), then work down
the list above.
Fixed in c33a2cf: esc() moved above its first use and applied to every
interpolation of a name, label or title — the toolbar, all five wrappers, the
site map entries and both gallery grids. Two neighbours in the same blast
radius went with it: the gallery's {{IMAGES_JSON}} now escapes < and
U+2028/9 rather than trusting JSON.stringify inside a <script>, and
template fills take a function replacer, because String.replace reads $&
and $` in a replacement string — a file named $&x.md was a substitution,
not a name. test/escaping.test.js covers the injection cases and asserts
plain names still render byte-identical.
2. The R2 publish empties the bucket before it uploads — FIXED 18 August 2026
main/cloudflare.js:719
Step 2 purges every object, step 3 uploads. Any failure in between leaves the live site serving whatever partial set made it. A Live R2 site that loses its connection after 3 of 200 files is a three-file site, and live sync's backoff ladder (30s → 600s) means a persistent cause leaves it broken indefinitely with nobody watching.
Fix: the Workers path in the same function already does it safely — upload everything, then deploy the version referencing the new manifest atomically. Give R2 the same ordering: upload under new keys, then swap, then purge what is no longer referenced. At minimum, do not purge until the upload has succeeded.
Fixed: the publish now uploads first, then calls pruneR2Bucket to delete
only the keys the new publish does not contain. Same-named objects are replaced
in place, so the live site is never missing content, and an upload that fails
part-way deletes nothing. purgeR2Bucket remains for deleteSite.
3. set_password can strand a site in an unpublishable state — FIXED 18 August 2026
main/mcp-server.js:943
It writes password_protected: true into cheldrop.yaml before publishing,
and does not revert when the publish fails (line 960).
Live sync then republishes from that yaml. performPublish reads
passwordProtected: true on an R2 backend, but the unattended path carries no
password and the site entry still says unprotected — so cloudflare.js:703
returns "Password protection is on but no password is set". Every retry hits
the same branch forever, asking someone to type a password who is not there.
Fix: write the yaml only after a successful publish, the way
performPublish already does at ipc.js:389. Or roll it back on failure.
Fixed: the pre-publish writeSiteConfig is gone. set_password now passes
passwordEnabled to manualPublish and nothing else — performPublish already
merges that into the config it publishes with, and writes the merged yaml back
only after the publish succeeds (ipc.js:389). A failed publish now leaves
cheldrop.yaml exactly as it was, so live sync has nothing unpublishable to
retry.
4. The two-publish guard gives up after 30 seconds — FIXED 18 August 2026
main/livesync.js:366
manualPublish awaits livesync.pause() so a watcher publish and a button
publish cannot collide. The wait has a deadline and no way to report reaching it:
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
if (![...watched.values()].some(s => s.publishing)) return // only success exit
await new Promise(r => setTimeout(r, 150))
}
// falls through after 30s — caller cannot tell this from success
Two replaceEverything: true publishes then race, and the one that lands last
wins — which can be the older snapshot, since the live publish started first.
The user sees an ordinary success.
Fix: return false on timeout; have manualPublish surface it rather than
proceeding. 30s is not generous by this module's own standards — RETRY_MS
opens at 30s assuming network stalls are common.
Fixed: pause() now returns true when the field is clear and false when
the deadline passes with a publish still running (the wait is PAUSE_DRAIN_MS,
exported, and overridable per call so tests need not sit out 30 s).
manualPublish checks that return and refuses — { ok: false, error }, the
shape every caller already handles — instead of starting a second full
replacement. The watchers are still held on the refusal path, so resume() in
the finally stays correct. test/livesync-pause.test.js drives a real watcher
with a publish held open and asserts the false, the true once it lands, and that
the caller's guard sits before the publish.
Fix soon — degrades the product
5. A dead bucket-wipe endpoint still ships on every R2 site — FIXED 18 August 2026
main/r2-worker.js:218
Every deployed R2 router Worker exposes POST /__cheldrop_admin__/purge, which
deletes every object in the bucket. Nothing calls it any more — cloudflare.js
purges through the R2 API instead and says so at line 545.
The guarding secret is stored unencrypted in sites.json as adminSecret, a
few fields from cfToken, which is safeStorage-encrypted. Anyone who reads
that file can destroy the site's contents with no other credential. The check at
line 219 is a plain !==, not the constant-time safeEqual used at line 197.
Fix: remove the endpoint from the generated Worker. If it must stay, use
safeEqual and encrypt adminSecret at rest.
Fixed: the endpoint is gone from r2-worker.js, and with it the reason
adminSecret existed — deployR2Worker no longer generates one, no publish
result carries one, and getDb() deletes the field from records written before
this, so the one plaintext secret in sites.json leaves disk. The deployed
Worker now has no write path to the bucket at all; the app does everything
through the R2 API with the token it already holds.
Deleting the source only helps sites published afterwards, so the second half is
a version gate: R2_WORKER_VERSION is stored per site as workerVersion, and a
publish redeploys the Worker when the deployed script is behind it.
That redeploy at first covered only unprotected sites — a deploy replaces the
bindings wholesale, and a protected site redeployed without a fresh auth record
(which needs the password; live sync has none) would come back public — which
left protected sites on the old Worker, endpoint and all, until the next time
the password was set. Now closed: the upload inherits the binding instead of
rebuilding it, with keep_bindings: ['secret_text'], so the password crosses
the redeploy without Cheldrop ever holding it.
Since a lost binding would fail silently and publicly, the inherit path is
bracketed by checkAuthBinding, which distinguishes present, absent and
unknown — the endpoint is GET /workers/scripts/{name}/secrets, which returns
binding names and never values. Absent or unknown beforehand means there is
nothing safe to inherit, so the old Worker stays. Anything but present
afterwards means the site may be answering without a password, so it is
re-deployed with an auth record built from 32 random bytes nobody holds — locked,
not public — and the publish fails asking for the password to be set again. The
same check also stops a site whose entry says public but whose Worker holds an
auth binding from being quietly unprotected by a router update.
test/r2-worker.test.js asserts the script carries no admin endpoint and no
delete call, and models the API's binding semantics — write-only secret values,
keep_bindings inheritance, and a knob that makes that inheritance fail — to
cover all seven redeploy decisions, including the lock.
6. Folder walking follows symlinks with no cycle guard — FIXED 18 August 2026
main/sitefolder.js:757
readFolderFiles uses fs.statSync, which resolves symlinks, with neither loop
detection nor a containment check against the folder root.
ln -s .. loop inside a site folder makes walk() recurse until stack
exhaustion or ENAMETOOLONG, during a publish or live sync, with no useful
error. ln -s ~/Documents refs publishes that whole tree to the public URL.
Fix: fs.lstatSync, and skip symlinks (or resolve and check containment).
Fixed: the walk resolves and checks containment rather than skipping links
outright, so a link used the way people use them — one file, one folder, inside
the site — still publishes. lstat identifies the link, realpath resolves it,
and anything landing outside the folder's own real path is skipped. Both sides
of that test are real paths: a site folder in Dropbox or iCloud is itself
reached through a link, and comparing the paths as written makes a folder fail
its own containment test.
Cycles are cut by the chain of directories open above the current one, not by
every directory seen. The difference showed up in the tests: dedup on
"seen" makes sub/ and a also-sub -> sub link race, and whichever readdir
returns second is dropped — so the real folder can lose to the link. On the open
chain, ln -s .. loop and ln -s . self terminate while a linked sibling
publishes under both names, which is what the folder actually holds.
Dangling links are skipped rather than thrown on — on macOS the old code did not
recurse forever, it aborted the publish with an uncaught ELOOP or ENOENT
from deep inside the walk. A missing folder still throws, as before.
test/folder-walk.test.js builds real folders and real links: the two exploits,
both cycles, the linked sibling, the file link, the dangling link, and a site
folder reached through a link. Six of its ten cases fail against the old walk.
7. Extensionless URLs work on the free tier and 404 after upgrading — FIXED 18 August 2026
main/r2-worker.js:253
The R2 router resolves /about to about/index.html, then falls back to
about — never about.html. The Workers backend is deployed with
html_handling: 'auto-trailing-slash' (cloudflare.js:373), which does serve
it. So a folder holding about.html answers /about on the free tier and 404s
once the same site moves to backend: r2.
Fix: add the <path>.html lookup to the router's fallback chain.
Fixed: the chain is now <path>/index.html, <path>.html, <path>, and
/about/ falls back to about.html as well. The candidate list is a superset
of the old one in the same order, so no address that resolved before resolves
to something else now — the folder index deliberately keeps precedence over the
sibling page, since that is what a live site is already serving. The only
behaviour that moves is a site holding both about.html and a bare about,
where the .html file now wins; that is the answer the free tier gives.
Two neighbours went with it. The dot test that decides "this address names a
file" read the whole path, so a directory called v1.2 stopped index resolution
for everything beneath it — it reads the last segment now. And
R2_WORKER_VERSION is bumped to 3, which is what carries the routing fix to
sites already live; with finding 5 closed, that now includes protected ones.
test/r2-routing.test.js evaluates the generated Worker and drives it with real
Request objects against a fake bucket that records every key looked for.
Five of its eleven cases fail against the old router.
8. Deleting a site can leave its hostname attached — FIXED 18 August 2026
main/cloudflare.js:808 (and :645)
deleteWorkerAndDomain reads /workers/domains with no pagination and treats
the first page as the whole account. Past that page it finds no match, skips the
detach, and deletes the script anyway — leaving the hostname bound to a missing
Worker and its DNS record behind. Re-publishing that subdomain later fails with
"That subdomain already has a DNS record", the error friendlyError
special-cases at line 51.
This is a known bug class here: the comment at line 184 records an account whose
19 Pages projects showed as 10. listR2Buckets paginates by cursor; only these
two domain lookups do not.
Fix: paginate, the way listPaged and listR2Buckets already do. The exact
account size that triggers it depends on Cloudflare's default page size for this
endpoint — unverified, no account was available.
Fixed: both lookups now go through findWorkerDomain, which asks for the
one hostname it wants — ?hostname=<fqdn>, the way deleteCnameRecord
already queries DNS — so the answer no longer depends on how many domains the
account holds. The filter is not trusted on its own: the match is confirmed
here, and a list that comes back holding other hostnames means the filter was
ignored, which falls back to a full paged sweep in listWorkerDomains.
That sweep is deliberately careful, because this endpoint's paging is
undocumented and the account to verify it against still was not available. It
sends page and not per_page — listPagesProjects records an endpoint
that rejects per_page with a 400, and a lookup whose failure mode is a
stranded hostname is the wrong place to risk it. Termination follows what the
response says rather than what it is assumed to do: no result_info means the
whole list arrived at once, total_pages is followed when given, a short page
ends the sweep when only per_page is given, and a page identical to page one
means page was ignored and stops it. Every path is bounded at 50 pages.
The attach side went with it, since it reads the same list: a re-publish on a
large account could not see the domain it already had, so it re-attached and
Cloudflare answered with the DNS-record clash friendlyError special-cases —
turning an ordinary republish into that error. The delete side is unchanged in
shape: still best effort, still deletes the script if the lookup itself fails.
test/worker-domains.test.js runs each case against three fake servers — one
honouring the hostname filter, one ignoring it and paging, one ignoring both
and answering with everything — because the fix has to be right whichever this
endpoint turns out to be. Four of its eighteen cases fail against the old
lookup, and the two publish-side failures fail with the DNS-record error
verbatim.
9. A file that disappears mid-publish freezes the UI — FIXED 18 August 2026
renderer/src/pages/Publish.jsx:500
The FileReader promise inside handlePublish can reject and nothing catches
it, so the function exits before cleanup() and before any error state is set.
status stays 'publishing' with no message and no way back but restarting,
and the onPublishProgress listener from line 461 leaks. Every other failure
path in the function calls cleanup() first.
Fix: wrap the read in try/catch, call cleanup(), set the error state.
Fixed: the read is wrapped, and the catch takes the same three steps every
other failure path in the function already takes — cleanup(), setStatus('error'),
a message — so a file that vanished between being picked and being published
ends as an ordinary error the user can act on, rather than a stuck spinner. The
message names the file and says to add the files again, since a File handle
whose backing file has gone will not read on a retry either.
10. Quitting does not wait for a publish in flight — FIXED 18 August 2026
main/index.js:114
before-quit calls livesync.stopAll(), which clears timers, closes watchers
and sets status off — then returns. The in-flight performPublish is a
floating promise nobody awaits, so the process dies mid-upload.
A Live site's debounce fires, the publish starts, the user presses ⌘Q two seconds later. On R2 that lands inside the purge-then-upload window of finding 2, so the live site can be left empty.
livesync.pause() already polls s.publishing for exactly this; stopAll()
just does not use it. tray.js:133 avoids role: 'quit' with the comment
"quitting has to stop the watchers first" — which implies more safety than
stopAll() delivers. And updater.js sets autoInstallOnAppQuit = true, so a
quietly downloaded update turns every ordinary quit into this path without the
user choosing it.
Fix: preventDefault() in before-quit, await a drain (reuse pause()),
then quit. Cap the wait so a hung publish cannot block quit forever.
Fixed: before-quit now holds the quit, calls livesync.pause() — the
same drain a manual publish uses, so the watchers are held and whatever is
uploading gets to land — and only then runs the teardown it always ran, before
quitting for real. The cap is 15 s rather than livesync's 30 s: this wait is in
front of a user who just pressed ⌘Q, and a hung upload must not make quit look
broken. When the deadline passes the app quits anyway, having tried. A second
⌘Q during the drain is held too — impatience is not permission to die mid-upload,
and the cap is what bounds the wait. test/quit-drain.test.js (npm run test:quitdrain) drives the real handler with a publish held open and asserts
the order: nothing is torn down while the drain runs, and the quit lands after
it either way.
11. One downloaded update disables checking for later ones — FIXED 18 August 2026
main/updater.js:150
update-downloaded sets downloadedVersion, and nothing ever clears it. From
then on checkForUpdatesManually returns early for the rest of the process
lifetime, re-offering the version already on disk.
Download 1.1.0, pick "Later". Two weeks on, 1.2.0 and 1.3.0 have shipped; "Check for Updates…" still says "Cheldrop 1.1.0 is ready to install", with no hint newer versions exist and no way to look. The only escape is quitting (installing the stale 1.1.0) or restarting.
This matters here for the reason the file's own header gives: Phase 4.3 makes Cheldrop a permanently-running login-item agent, so process lifetime is weeks.
Fix: re-check anyway, and replace the pending download when the feed offers something newer.
Fixed: the early return is gone — "Check for Updates…" always reaches the
feed, and the handlers decide what to do with what came back. autoDownload is
off so the decision is ours: a version already on disk is kept and offered for
install rather than fetched a second time, and anything newer supersedes it —
cancelling an in-flight download through its CancellationToken when one is
running. The downloaded file stays installable on quit until its replacement
lands, so a supersede that never finishes still leaves the user an upgrade.
Ordering is a local semver compare rather than the transitively-hoisted
semver: 1.10.0 has to read as newer than 1.9.0, and a prerelease as older
than the release it leads to. Two dialogs stopped lying on the way: a pulled
release now offers the ready update instead of "you are up to date", and a
failed check offers it instead of leaving the user an error with no way to
install what is already downloaded. test/updater-recheck.test.js (npm run test:updaterecheck) drives the module as one session — download, "Later",
weeks of newer releases — with a fake feed, and asserts the check happens every
time, that nothing is downloaded twice, and that the supersede cancels.
Minor
12. A comment promises a hash the code never applies — FIXED 18 August 2026
main/ipc.js:441
// Use a hash of the name to avoid collisions across concurrent publishes
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_')
Sanitising blocks traversal (separators become _, a bare .. fails as
EISDIR), so the security half is sound. It does nothing about collisions: a
manual publish and an MCP publish_now staging index.html at the same moment
write the same path, and one site publishes the other's file.
Fix: hash as the comment says, or correct the comment.
Fixed: a hash of the name would not have helped — the collision is two
publishes staging the same name, which hashes the same. Each call now names
its file after the call: a pid/counter/random stamp in front of the sanitised
name, so concurrent publishes cannot meet. The stamp goes in front because the
extension decides content type and the Workers asset hash, and the publish
payload carries the real name beside the path, so no downstream reader loses
anything. Sanitising stays and keeps doing the job it was actually doing —
containing the write — with a fallback name so a name that sanitises to nothing
cannot address the directory itself. Unique names never overwrite, so the
staging directory is swept of day-old files on each publish; publishes take
seconds, so anything older is litter. test/temp-stage.test.js (npm run test:tempstage) stages index.html twice and asserts each publish keeps its
own bytes, and that traversal still does not survive.
13. The tray menu rebuilds once per uploaded file — FIXED 18 August 2026
main/tray.js:201
The livesync.onStatus handler calls refreshSites() on every status event,
and progress events fire per file (cloudflare.js:512). Each one runs
buildTrayMenu() → Menu.buildFromTemplate, setContextMenu, setToolTip,
and trayIcon(), which constructs a fresh nativeImage from the PNG path.
A 200-file R2 publish does all of that 200 times in a few seconds. The visible
symptom is the tray menu churning while the user has it open — replacing a
macOS context menu mid-display disrupts it. The db read is not the cost
(getDb() memoises); the native menu construction and image loading are.
Fix: rebuild on state transitions only, or coalesce on a short timer.
Fixed: both, split by what the event actually changes. A site that starts,
lands or fails changes the glyph, the icon and the tooltip, so a state
transition still rebuilds at once — the menu bar must not lag the thing it
reports. Progress inside a publish changes one line of text and arrives once
per file, so those coalesce onto a 1 s trailing timer: the detail still lands,
the last one wins, and a 200-file publish costs one rebuild instead of 200. The
timer is cleared in destroyTray(), so a quit mid-publish cannot fire a
rebuild at a destroyed tray a second later. test/tray-menu.test.js (npm run test:traymenu) drives a 200-file publish through the real handler and asserts
the collapse, that the menu ends on the last progress line, and that landing
still draws immediately.
Checked and found sound
Recorded so this is not read as a fault list only. Each is a place a specific bug was looked for and the handling was already correct.
- Electron hardening —
contextIsolation: true,nodeIntegration: false, a fixed verb list in preload, nodangerouslySetInnerHTMLanywhere. - Token storage — safeStorage round-trip, prefix-marked ciphertext, in-place plaintext migration, and the encryption-unavailable fallback.
- MCP transport — 127.0.0.1 only,
crypto.timingSafeEqualwith a length pre-check, Origin allowlist against DNS rebinding, 1 MB body cap. - Licence gates — free-tier and R2 gates enforced in
performPublish, so a hand-editedcheldrop.yamlcannot walk past them. - Licence resilience — inconclusive checks keep the activation inside a 7-day grace window; only a definitive rejection revokes.
- Reconciliation — one unreadable account makes a kind unknown, not missing.
safeHref— correctly rejectsjavascript:, including newline/tab variants.- Path containment —
readTheme,readBundledTheme,materializeSiteFolder. writeSiteConfigskips identical writes, which is what stops a Live folder publishing itself in a loop.- js-yaml 5.2.3 —
loadis safe by default.
Scope — read this before trusting a gap
Not every file got the same depth.
Read line by line (~6,100 lines): preload.js, index.js, ipc.js,
livesync.js, cloudflare.js, r2-worker.js, sitefolder.js, reconcile.js,
menu.js, mcp-server.js, tray.js, updater.js; renderer.js lines 1–200;
license.js lines 1–80; Publish.jsx around the publish handler.
Targeted search only — greps for specific bug classes, not a full read:
renderer.js lines 200–1265, the rest of license.js, Sites.jsx,
Settings.jsx, Docs.jsx, QuickStart.jsx.
Not opened: test/ (798, read for coverage only), build/ scripts,
bundled templates.
No finding was verified against a live Cloudflare account.
The separate review of commit 6d923a4 (the 16px mark fallback) carries two
findings of its own: a favicon ring that still renders as a sub-pixel hairline
(build/brand/mark-dot-light.svg:5), and tray geometry that has drifted from
the SVG both files cite (build/tray/make-tray-icons.py:53).