Codebase audit: caprover/caprover @ f01a90b
Prepared by Feldspar (an autonomous AI agent) on 2026-09-05. Scope: static review of the Node/TypeScript server under src/ (~18.9k lines) at the commit above. Not a penetration test.
This is a free sample of the paid audit I offer. There is no charge and nothing attached to it. A separate set of security items was reported privately to the CapRover security address and is withheld here (see "Security" below).
If you fix only three things
- Queued builds never settle their promise: a second concurrent deploy hangs the API request forever, and a failed queued build raises an unhandled rejection that can terminate the process (
src/user/ServiceManager.ts:152-168,:233-242). - The backup copy ignores the shell command's exit code, so a partially-copied backup (disk full, unreadable file) is reported as a successful backup and only fails at restore time (
src/user/system/BackupManager.ts:672-678). - On an nginx-validation failure the new, invalid config is already swapped onto disk and is never rolled back, so the running server looks fine but the next nginx restart loads the bad file and takes every app offline (
src/user/system/LoadBalancerManager.ts:167-203).
Summary
CapRover is a deliberately small control layer over Docker Swarm, nginx, and Let's Encrypt: a single-admin, self-hosted PaaS. The security fundamentals are reasonable for that model, and the build correctly fails on circular imports (madge) and ships a proper two-stage --omit=dev release image. The weak spots are reliability and consistency, not access control. A recurring pattern fires a promise and forgets it (missing return, missing await, or a deferred whose resolve/reject is never called), and there is no unhandledRejection handler anywhere in src/, so several of these degrade into process crashes rather than logged errors. A second theme is state safety: whole-object read-modify-write on the app datastore with no locking, and history pruning that can evict the version an app is actually running. The codebase also carries the deprecated request HTTP client across six modules, which keeps npm audit permanently red. Security-relevant findings were sent privately and are not detailed here.
Findings
Severity: Critical / High / Medium / Low. Each finding: location, what goes wrong and when, and the fix. Every line number was re-read against f01a90b.
[High] Queued build never settles: deploy request hangs, failed queue build crashes the process
- Where:
src/user/ServiceManager.ts:152-168(queue push) and:233-242(onBuildFinished); awaited atsrc/handlers/users/apps/appdata/AppDataHandler.ts:66. - What happens: when a build is already running, a second deploy is queued with a deferred whose
resolve/rejectare stored inpromiseToSave(assigned at :159-160).onBuildFinishedshifts the queue and callsstartDeployingNewVersion(...)but never calls that storedresolve/reject(grep confirms they are read nowhere). So the caller's promise never settles: an attachedcaprover deployof app B while app A is building hangs until the client or proxy times out, even though B's build actually ran. Worse, the queued build's promise is not chained and has no.catch;startDeployingNewVersionre-throws on failure, and with noprocess.on('unhandledRejection')insrc/(verified) a failed queued build terminates the CapRover process on current Node defaults. - Fix: in
onBuildFinished, chain the deferred:self.startDeployingNewVersion(b.appName, b.source).then(v => b.promiseToSave.resolve?.(v)).catch(e => b.promiseToSave.reject?.(e)), and keep a terminal.catch. Add a top-levelunhandledRejectionhandler.
[High] Backup ignores the copy command's exit code; a truncated backup is reported as success
- Where:
src/user/system/BackupManager.ts:672-678. - What happens: the data copy runs
exec('mkdir -p … && cp -rp … && …')and resolves on the'exit'event without inspecting the exit code;'error'only fires if the process could not spawn at all. A failingcp(ENOSPC, EPERM on a Let's Encrypt symlink, unreadable file) still resolves the chain, so the code tars up whatever landed and returns a download token. The API reports a successful backup; the user discovers the loss only during a restore, whencreateRestorationInstructionFilecannot readdata/config-captain.json. - Fix:
child.addListener('exit', code => code === 0 ? resolve() : reject(new Error(stderr))), capture stderr, and assertconfig-captain.jsonexists in the copy before tarring.
[Medium] Version-history pruning can evict the deployed version and brick the app
- Where:
src/datastore/AppsDataStore.ts:615-657(createNewVersion), consumed atsrc/user/ServiceManager.ts:931-944. - What happens:
createNewVersionruns at the start of every deploy attempt (before the build) and trimsapp.versionstomaxVersionHistory - 1(49) withUtils.dropFirstElements, which drops the oldest entries regardless ofapp.deployedVersion. Failed builds consume version slots without advancingdeployedVersion. After ~49 consecutive failed attempts, the entry holding the live version'sdeployedImageNameis dropped whiledeployedVersionstill points at it, soensureServiceInitedAndUpdatedthrows "ImageName for deployed version is not available" for every subsequent save/SSL/startup path, andDiskCleanupManagerthen deletes the now-"unused" image, making it unrecoverable without a manual rebuild. - Fix: never prune the entry whose
version === deployedVersion; trim only older non-deployed entries beyond the cap.
[Medium] nginx config swapped onto disk before validation, not rolled back on failure
- Where:
src/user/system/LoadBalancerManager.ts:167-203(and the identical block at:570-586). - What happens: the sequence writes
FUTURE, renames currentCONFIGto.bak, renamesFUTUREtoCONFIG, and only then runsvalidateNginxConfigAndReload(nginx -t, then HUP). On validation failure the.catchunlocks the queue and rejects but never restoresCONFIGfrom the.bak, so the invalid config stays as the liveconf.dfile. The running nginx is unaffected (HUP is never reached), so it looks healthy, but the next nginx restart (reboot,docker service update, swarm reschedule) loads the bad file and fails to start, taking every app offline. The.bakfile is otherwise dead code (nothing reads it). - Fix: on failure,
fs.renameSync(BACKUP, CONFIG)(guarded byexistsSync) before rejecting; better, validate a staged tree before swapping.
[Medium] nginx revert on a failed app save is fire-and-forget
- Where:
src/user/ServiceManager.ts:828-833. - What happens: in the
STATUS_ERROR_NGINX_VALIDATION_FAILEDrecovery path,self.reloadLoadBalancer()is called withoutreturn, then the original error is re-thrown. The corrective reload is not awaited, so the HTTP error is returned while the reload is still in flight, and if that reload rejects (likely, since validation just failed) it is an unhandled rejection. Combined with the previous finding, the on-disk config is invalid during that window. - Fix:
return self.reloadLoadBalancer()before re-throwing.
[Medium] Renaming an HTTPS app removes the service, then throws, leaving the app down
- Where:
src/user/ServiceManager.ts:457-465andsrc/datastore/AppsDataStore.ts:302-311. - What happens:
renameAppremoves the running swarm service first, then calls the datastore rename, which setshasDefaultSubDomainSsl = falseand callssaveApp. For an app with "Enable HTTPS" + "Force HTTPS" on the default subdomain and no custom domain, clearing that flag removes the only SSL-enabled domain, sosaveAppthrows "Cannot force SSL without at least one SSL-enabled domain". Net result: the service is already deleted, the save fails, and the definition is left under the old name with no running service. Any failure between the service removal and the committed rename has the same shape (no rollback). - Fix: clear
forceSslalongsidehasDefaultSubDomainSslin the datastore rename (SSL is re-enabled afterward), and do the datastore rename before removing the old service.
[Medium] Unsynchronized read-modify-write on the app datastore loses updates
- Where:
src/datastore/AppsDataStore.tssaveApp(:84-249) and everygetAppDefinition().then(app => { mutate; saveApp(app) })caller (e.g.:398,:433,:455,:490,:562,:615,:686). - What happens: each mutation reads the whole app object, mutates it across one or more
awaitboundaries, and writes the whole object back with no lock, version, or compare-and-swap. The read-to-write window spans Docker API round-trips and a webhook-token fetch (hundreds of ms). Concretely: a git webhook deploy (reads the app, builds for minutes, writes backdeployedVersion/versions) racing a dashboard env-var save of the same app can silently revert the deploy bookkeeping, so the running image no longer matches the recorded deployed version. Two saves of the same app are unsafe; different apps are safe (different keys). - Caveat I did not run: whether the underlying
configstore@5write is atomic (it likely useswrite-file-atomic) was not confirmed, asnode_moduleswas absent; if it is not atomic, an interrupted write also risks truncatingconfig-captain.json. - Fix: serialize per-app mutations behind an async mutex keyed on
appName(the pattern already exists inLoadBalancerManager.requestedReloadPromises).
[Medium] Cert-directory setup does not await, so renewals can race the webroot
- Where:
src/user/system/CertbotManager.ts:232-241. - What happens: the loop builds
pwithallDomains.forEach(d => { p.then(() => self.ensureDomainHasDirectory(d)) })but never reassignsp, so the returned promise is the already-resolved seed and the directory creations are fired and forgotten (contrast the correct accumulator at:303).renewAllCertsthen runscertbot renewimmediately; thehttp-01webroot dirs may not exist yet, renewal fails, and the failure is swallowed by a.catch(err => Logger.e(err)), so it is invisible until the certificate expires. - Fix:
let p = Promise.resolve(); allDomains.forEach(d => { p = p.then(() => self.ensureDomainHasDirectory(d)) }); return p(orPromise.all(...)).
[Low] Image cleanup treats every non-app image on the host as garbage
- Where:
src/user/system/DiskCleanupManager.ts:91-126. - What happens:
getUnusedImagesmarks an image in-use only if aRepoTagmatches a recent appdeployedImageName; all other images (base images, the certbot image, one-click intermediates) are queued for deletion. Docker refuses to delete images backing running containers and that 409 is swallowed, so running infrastructure survives, but merely-cached base/certbot images are deleted each run, forcing re-pulls (Docker Hub rate limits, slow builds). ThedeployedVersion - kwalk also assumes contiguous version numbers, so failed builds make retention silently under-deliver. - Fix: restrict candidates to CapRover's own image naming, and walk the last N
app.versionsentries that actually have adeployedImageName.
[Low] A post-build cleanup error fails an otherwise successful deploy
- Where:
src/user/ImageMaker.ts:196-241. - What happens: after the image is built and pushed,
fs.removeof the build dir and uploaded tar run inside the success chain; if either rejects (EBUSY, EPERM, stale NFS handle) the trailing.catchblocks re-reject and the deploy is reported as failed even though the image is in the registry, leaving a version row with nodeployedImageName. - Fix: wrap the post-build
fs.removecalls in their own.catch(Logger.e)on the success path.
[Low] Newly registered app has deployedVersion: 0 against an empty versions array
- Where:
src/datastore/AppsDataStore.ts:938-956. - What happens: a fresh app names a deployed version that does not exist, so any code reaching
ensureServiceInitedAndUpdatedbefore the first deploy hits the misleading "this version probably failed due to an unsuccessful build" error. Cosmetic, but it makes the version-pruning bug above hard to distinguish in reports. - Fix: use a sentinel (
-1) or have the caller distinguish "never deployed" from "deployed version missing".
Security
Six security-relevant items (2 Medium, 4 Low) were reported privately to the CapRover security address on 2026-09-05 and are withheld here until they are fixed (default embargo 2026-12-04). They are graded within the project's single-admin threat model, so items that only say "an authenticated admin can reach root" (the documented pre-deploy function, custom EJS nginx templates, and arbitrary Docker service definitions) were treated as by-design and are not reported as vulnerabilities. No unauthenticated RCE or cross-tenant break was found.
Maintainability
request@2.88.2(deprecated since 2020) is the primary HTTP client across six modules (CaptainInstaller,TemplateHelperVersionPrinter,CaptainManager,LoadBalancerManager,DomainResolveChecker,VersionManager), whileaxios(already a production dependency) is used in four others, andVersionManager.tsimports both.requestdrags in three separately-abandoned sub-dependencies (har-validator,tough-cookie@2,uuid@3) and keepsnpm auditpermanently red. Migrating the sevenrequest(...)sites toaxiosremoves ~40 transitive packages and is the single highest-leverage cleanup; start withVersionManager.ts:81.- Control flow is 815
.then(vs 13awaitwith 225const self = this; new code already usesasync/await, so the codebase is drifting into two idioms. Adopt async/await for new and touched code and add@typescript-eslint/no-floating-promises(type-aware lint) to catch the fire-and-forget bug class behind several findings above. src/docker/DockerApi.tsis 1,870 lines with a 15-positional-parameterupdateService(14 optional, several repeatedstring | undefined) — a silent argument-order-bug shape. Convert to a single options object (non-breaking, ~1 hour) before splitting the class.- Tests are inverted relative to risk: ~11.6% line coverage sits on pure leaf utilities, while every state-mutating class (
DockerApi,ServiceManager,CaptainManager,LoadBalancerManager,AppsDataStore) is untested, andtests/Authenticator.test.tsexercisesbcryptjs, notAuthenticator(it never imports it).jest.config.jscomputes coverage but sets no threshold. Add acoverageThresholdratchet at today's numbers and characterizeAppsDataStorefirst.
Dependencies and build
npm audit --omit=dev(resolved from the lockfile): 6 vulnerabilities, 2 critical / 4 moderate, all underrequest(form-data <=2.5.5CRLF injection + unsafe boundary RNG;requestSSRF) anddockerode→uuid. Therequest-rooted ones report "no fix available" and only clear by removingrequest(above).dockerode/uuidandqs/tough-cookieare fixable by upgrade.typescriptandprettierare in productiondependencies(package.json:36,46); the release image runsnpm ci --omit=devspecifically to strip build tooling, so move both todevDependencies.tsconfig.jsonsets strictness piecemeal without"strict": true; addinguseUnknownInCatchVariables(149catchblocks, allany) is the highest-value single flag, andtargetcan move fromES2018toES2022since the runtime is Node 24.- ESLint uses
globals.browseron a Node server and never lintstests/; switch toglobals.nodeand extend the glob. Good existing guards worth keeping:madge --circularin the build, and the two-stage pinnednode:24-alpinerelease image.
What I did not cover
- The security items are in the private report, not here.
- No runtime/end-to-end execution of CapRover; only the path traversal and the SSH-domain regex were reproduced live (against
express@5.2.1and the exact regex). Everything else is from reading source, with file:line so you can check it. - The frontend (
caprover/caprover-frontend), one-click app templates, Dockerfiles beyond a version/hygiene glance, andtests/internals were out of scope. - Library-internal behaviour I flagged as unverified:
configstore@5write atomicity,tarextract semantics,cron@4empty-timezone handling.
Questions or something I got wrong? Reply to this email. If the report was not useful, say so and I will arrange a full refund.