This document describes Quill's threat model, the trust boundaries it defends, and the deliberate decisions behind what is — and isn't — hardened. It is meant to be read by an engineer reviewing the app, not just to list mitigations. Where we left something unchanged, the rationale is here too, because a defensible non-fix is part of the posture.
If you find a security issue in Quill, please report it privately rather than opening a public issue, so it can be fixed before it's widely known. Email sapowers16@gmail.com with a description and, where possible, steps to reproduce. You'll get an acknowledgement, and we'll work the fix and any disclosure timing with you. There is no bug-bounty program — this is a small open-source project — but reports are genuinely welcome and credited if you'd like.
Quill is a local, single-user macOS desktop app (Tauri 2 + a React/TypeScript
webview). It edits Markdown files the user already has on disk, stores
annotations in a sidecar next to each document, and — when the user opts in —
shells out to the locally-installed claude CLI to get AI replies on a document.
There is no server, no account, no multi-tenant data, and no network listener.
That shape rules out whole categories of risk (no auth, no session management,
no server-side injection) and concentrates the real risk in three places:
- The webview. Untrusted content (the Markdown being edited, including links and image URLs) is rendered in a context that also has IPC access to the Rust backend. Classic desktop-webview concern: don't let document content become code or navigate the user somewhere hostile.
- The IPC / filesystem boundary. The Rust backend exposes file read/write/ delete and process-spawn commands to the webview. Anything that can drive those commands can touch the filesystem.
- The deep link.
quill://open?file=…is registered with the OS. Any web page the user visits can fire it. This is the one entry point an external attacker can reach without already being on the machine, so it gets the most scrutiny.
Both passes over the codebase reached the same conclusion, and it's worth stating plainly because it shaped every fix:
Quill trusted deserialized data more than it trusted rendered data.
The rendered side was already in good shape. The CSP is restrictive
(default-src 'self', no unsafe-eval, scripting confined to the bundle), the
webview can't be navigated to arbitrary origins, and the tracked-edit machinery
computes ProseMirror positions defensively. Link and image rendering were the
two soft spots there, and both are now closed.
The deserialized side was where trust outran validation. Three on-disk inputs —
the .comments.json sidecar, the draft.json crash-recovery snapshot, and the
quill:// deep-link target — were read back and largely believed. A sidecar is
just a file next to a document; a user can receive a .md + sidecar pair from
anyone. A draft is written by us but can be corrupted. A deep link is
attacker-influenced by definition. None of these had earned the trust they were
getting. Hostile or merely corrupt annotation data could reach
doc.resolve(pos) in ProseMirror, which throws on a negative / fractional / NaN
position and white-screens the app on open — a denial-of-service on a
document the user can no longer open, with no obvious recovery.
The hardening below is organized around closing that gap.
| Boundary | Input | Trust before | Trust after |
|---|---|---|---|
| Webview render | Link href, image src in the document |
Rendered as authored | Scheme-allowlisted; remote img constrained by CSP |
| IPC → filesystem | read_file / write_file / delete_file path |
Any path the webview passed | Extension-confined to documents Quill manages |
| OS → app | quill://open?file=… target |
Decoded and opened | Canonicalized, must be an existing regular .md |
| Disk → state | .comments.json sidecar, draft.json |
Spread into state largely as-is | Sanitized to a known-valid shape before use |
| App → process | claude binary resolution |
First matching string spawned | Must resolve to a real file |
The three file commands (read_file, write_file, delete_file) each call
ensure_allowed_path before touching the disk. The backend legitimately serves
user-chosen arbitrary paths (the native open/save dialog can land anywhere), so
confinement is by extension allowlist, not directory — Quill only ever reads
or writes files it manages:
fn ensure_allowed_path(path: &str) -> Result<(), String> {
let lower = path.to_ascii_lowercase();
let allowed =
lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".comments.json");
if allowed { Ok(()) } else { Err("Refusing to access a file Quill does not manage".to_string()) }
}This means a compromised or buggy webview can't use these commands to read
~/.ssh/id_rsa or write an executable into a launch directory — the command
refuses anything that isn't a document or its sidecar. (Crash-recovery uses
separate write_draft / read_draft / delete_draft commands scoped to the
single app-data draft.json, so they aren't — and don't need to be — covered by
this allowlist.)
The deep link gets a second, stricter check. parse_quill_open hands the decoded
target to validate_open_target, which: requires an .md / .markdown suffix →
canonicalizes the path (resolving symlinks) → requires the result to be an
existing regular file → re-checks the suffix on the canonical path (a
symlink could be named .md while pointing elsewhere). A web page firing
quill://open?file=/etc/passwd, …?file=/some/dir, a non-existent path, or a
.md symlink aimed at a device or secret all get rejected. The deep link can
only ever open a real Markdown document the user already has on disk.
src/utils/annotationValidation.ts is the single source of truth for what a
valid annotation looks like, used at both deserialization boundaries — the
sidecar (useFileManager.ts) and the draft snapshot (useDraftAutosave.ts) —
rather than duplicating guards in each. It exports sanitizeComments,
sanitizeSuggestions, sanitizeAISession, and sanitizeContextFolder.
The load-bearing helper is position validation, because that's what protects ProseMirror:
function toPosition(v: unknown): number | null {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return null;
return Math.floor(v);
}A comment or suggestion with a missing id, a non-finite / negative / fractional
position, or an unknown type is dropped rather than spread into editor state. A
comment's from/to are normalized (min/max) so an inverted range can't
slip through. sanitizeAISession is all-or-nothing: the binding is accepted only
if provider === 'claude-code' and sessionId/cwd/linkedAt are all present,
so a half-formed binding can't drive a process spawn later. The white-screen-on-
open DoS is closed: a hostile or corrupt sidecar degrades to "some annotations
were dropped," never to a position that throws.
Two places turn stored strings into something the user can navigate to, and both now refuse anything outside a known-safe set.
- Link marks (
Toolbar.tsx,normalizeHref): a link the user types is persisted into the saved.mdand is later clickable, so ajavascript:,data:,vbscript:, orfile:href would be a stored-XSS / local-file vector that survives a save/reopen.normalizeHrefpasses in-page/relative refs (^[#/.]), accepts a scheme only if it's in['http', 'https', 'mailto', 'tel'], gives bare domains anhttps://prefix, and returns empty for everything else. - The update banner (
useUpdateCheck.ts): "View release" opensrelease.html_urlfrom a network response in the user's browser.safeReleaseUrlaccepts it only if it parses ashttps://github.com/…, otherwise falls back to the hardcoded releases page. A spoofed or compromised API response can't redirect the user to an arbitrary scheme or host.
resolve_claude_binary locates the claude CLI across PATH, common install
locations, and a login shell. Every path now gates on candidate.is_file()
before returning, so a stray line of which output or a profile banner printed
by the login shell can never be handed to Command::new. The login-shell probe
uses -lc (non-interactive login) rather than -lic: we want the profile's
PATH, not the side effects of interactive-only rc blocks.
Two things look like findings at a glance and were left unchanged after thinking through the actual risk. Documenting why is the point — a reviewer should be able to see these were decisions, not oversights.
Relative image paths in a document are resolved against the document's directory
and loaded through Tauri's asset protocol, whose scope is $HOME/**
(tauri.conf.json). A narrower scope would be tempting, but Quill is a
general-purpose editor: a user can open a Markdown file anywhere under their home
directory, and its images must load. There is no narrower scope that doesn't
break the core feature. The scope grants the webview read access for display to
files under $HOME via the asset protocol — but an <img> can render a file's
bytes, it cannot read them back into script and exfiltrate them, and the CSP
forbids the kind of scripting that would be needed to try. The exposure is
"display an image the user's own document points at," which is the feature
working as intended. We keep $HOME/** knowingly.
Markdown permits , and the CSP's img-src allows https: so
those images render. Loading a remote image leaks the fact-of-open and the
client IP to that host — an ordinary web-beacon. This is inherent to Markdown
rendering, not specific to Quill, and the same trade-off every Markdown viewer
and email client makes. It is bounded: an image request carries no document
content, only the request itself, and the CSP confines remote image loads to
https:. We accept it rather than break remote images or build a
load-images-on-click gate that no comparable editor ships. A privacy-conscious
user editing untrusted documents should be aware that opening one may load remote
images — the same caution that applies to opening an HTML email.
To be honest about the boundary: Quill does not defend against an attacker who already has code execution on the machine or write access to the user's home directory — at that point the deep link and sidecar are the least of the user's problems. It is not code-signed or notarized beyond the standard release path, and there is no auto-updater (the update story is a notify-only banner by design). The threat model is "a normal user opens documents, some of which they didn't author, and visits web pages that might fire a deep link" — and within that model, the four fixes above close the gaps the audits found.