VaultReader is a personal-vault tool. The threat model below assumes:
- Trusted operator — the person deploying owns the vaults and trusts themselves.
- Single primary user behind a forward-auth proxy (Authelia, oauth2-proxy, Tailscale, etc.).
- Untrusted internet — anyone may probe
/share/<token>URLs.
It is not designed for:
- Multi-tenant deployments where users have different access levels.
- Sharing a deployment with strangers without a reverse proxy.
- Hostile-input scenarios where the vault contents themselves are adversarial.
Public internet
│
▼
Reverse proxy (Authelia / oauth2-proxy / cloudflared / Tailscale Funnel)
│ ← here is where SSL terminates and where most auth happens
▼
VaultReader container
│ ← here is where rw_paths + admin_token apply
▼
Filesystem (vaults + appdata)
VaultReader assumes the proxy in front of it has done the user-auth. Its own protections are layered on top, not as a replacement.
Recommended. Put Authelia / oauth2-proxy / Cloudflare Access in front. VaultReader has no concept of users. Anyone who reaches the container can read everything, write where rw_paths allows, and create share links if they have an admin token.
For admin endpoints only (/api/admin/*):
- Generate:
openssl rand -hex 32 - Configure: drop into
appdata/config.json→admin_token - Use:
X-Admin-Token: <token>header on every admin request
If admin_token is empty, all admin endpoints return 403 admin not configured. The browser UI doesn't expose an interactive way to set the header — admin operations require curl. This is intentional friction.
Compared with subtle.ConstantTimeCompare to prevent timing attacks.
Each share is a 24-hex-character token from crypto/rand that grants:
- Read access to one specific note (path baked into the token's record).
- Optionally write access if
writable: truewas set at creation. - For the duration of
expires_at(0= never).
Tokens are stored server-side in appdata/shares.json. They're not signed — possession is sufficient. Anyone with the URL can access the shared note, including following a forwarded email or browser-history-leaked link.
Use expires_at for sensitive shares. Treat share URLs like API keys.
240 requests/minute per IP, sliding window. Identifies the IP via X-Real-IP, falling back to X-Forwarded-For (left-most hop), falling back to r.RemoteAddr.
This is a soft DoS mitigation, not a security boundary. A determined attacker can rotate IPs.
Every endpoint that takes a path parameter runs it through safePath(vault, path):
func (s *server) safePath(vaultP, notePath string) (string, bool) {
if notePath == "" || strings.HasPrefix(notePath, "/") || strings.HasPrefix(notePath, "\\") {
return "", false
}
full := filepath.Clean(filepath.Join(vaultP, notePath))
rel, err := filepath.Rel(filepath.Clean(vaultP), full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", false
}
if full != vaultP && !strings.HasPrefix(full, vaultP+string(filepath.Separator)) {
return "", false
}
return full, true
}This blocks ../etc/passwd, absolute paths, and Windows-style \foo\bar. Verified by integration test against ?path=../etc/foo.md returning 400.
isWritable(vault, path) checks the supplied vault/path against every entry in rw_paths:
for _, rw := range rwPaths {
if rw == vault || full == rw || strings.HasPrefix(full, rw+"/") {
return true
}
}
return falseWhen the list is empty, every write returns 403. Add "pessoal" to allow the entire pessoal vault. Add "pessoal/agents/hermes/skills" to only allow writes inside that subtree.
This applies to:
PUT /api/note,POST /api/note,DELETE /api/notePOST /api/uploadPOST /api/move,POST /api/folder,DELETE /api/folderDELETE /api/attachments
- Note PUT: no explicit cap, relies on the rate limiter and Go's default
MaxBytesReader(none). A pathological client could flood with huge bodies. Add a cap if your deployment is exposed. - Upload POST: 10MB hard cap via
http.MaxBytesReader. - Admin POST: 32KB cap.
appdata/config.json and appdata/shares.json are written via tempfile + os.Rename — never partial-write a corrupted config. Crash during write leaves the previous version intact.
Soft-deleted files use the VRTRASH_<base64url(originalPath)>_<unix><ext> scheme. The base64-encoded full path makes restore exact for any filename — no round-trip ambiguity, even for paths containing __ or starting with _. The legacy __→/ flatten scheme is still readable for entries created before this change (via legacyDecodeTrashName) but new entries use the safer scheme.
This isn't an authorization boundary — anything in .trash/ was already deletable by the user — but it's a correctness property: undo via the toast UI relies on the original path being recoverable.
Every write through saveNote runs normalizeMarkdown: strip trailing whitespace per line, ensure exactly one trailing newline. This is a quality-of-life behavior for git diffs (round-tripping cleanly between editors), not a security control.
http.FileServer over the embedded FS doesn't allow directory listings (Go's default behavior emits 404 for missing paths but lists for collections). For the WebDAV mount, listing IS exposed via PROPFIND — that's the protocol's whole point.
The /share/<token>/asset?name=<X> route serves bundled JS/CSS/fonts so shared notes can render mermaid + KaTeX. The allowlist is strict by name:
var shareAssetAllowlist = map[string]string{
"mermaid.min.js": "static/mermaid.min.js",
"katex.min.js": "static/katex.min.js",
"katex.min.css": "static/katex.min.css",
"katex-auto-render.min.js": "static/katex-auto-render.min.js",
}Plus a regex-equivalent allow for fonts/<name>.{woff2,woff,ttf}. Anything else returns 404 — even files that exist in the embedded FS (index.html, style.css, obsidian.svg, etc.). A leaked share token cannot be used to fetch the SPA shell or any other private static file via this route.
The /share/<token>/file?path=<X> route (used by image embeds in shared notes) enforces two layers:
safePathagainst the share's vault — blocks path traversal.- File-extension allowlist — only images, PDF, common audio + video formats. Notes (
.md) and arbitrary other files return 403, so a leaked token can't be used to read other notes via this route.
VaultReader doesn't set CSP / HSTS / X-Frame-Options itself — that's the proxy's job. If you're deploying without a proxy (don't), at minimum add:
add_header X-Frame-Options "DENY";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' data:; script-src 'self'; style-src 'self' 'unsafe-inline';";Note: the inline <script> blocks in index.html require 'unsafe-inline' for scripts — there's no nonce strategy. CodeMirror / Mermaid / KaTeX also use eval/Function dynamically. If strict CSP is required, adopt a build-step that nonces the inlines.
- No request signing. Share links are bearer tokens — no HMAC, no rotation. Replays work.
- No login flow. VaultReader has no notion of session. The proxy's session is the session.
- No file watcher. If you edit a note via WebDAV (write) it would race the in-memory index. (Currently moot since WebDAV is read-only.)
- No audit log. There's no record of who read what, who shared what, or who changed
rw_paths. - No CSRF token. Mutating endpoints rely on browser same-origin policy. If you put a CORS-permissive proxy in front, you've broken this.
appdata/shares.jsonkeeps revoked entries in the file (the in-memory store filters them on every list, but the JSON contains all entries until next save).- WebDAV exposes
.obsidian/,.smart-env/,.trash/— internal directories. Filter at the proxy if you need to hide them.
- Reverse proxy with forward auth (Authelia, oauth2-proxy). Don't expose VaultReader directly.
- Path exemption for
/share/so shared links don't require login. - No exemption for
/webdav/— gate behind the same auth as the rest unless you specifically want anonymous WebDAV (don't). - Set
admin_tokento a long random string. Never commit it. - Use
expires_aton share links for anything sensitive. rw_pathsonly what you need to edit from the web. Vaults you only browse don't need to be writable.- Run as non-root in the container (the scratch image runs as root by default; consider
USER nobody). - Mount
/vaultsread-only if you only want web-side reading.rw_pathsbecomes a no-op then.
For security issues, don't open a public GitHub issue. Email the maintainer directly (see git log --format='%ae' | sort -u). For everything else, GitHub issues are fine.