Security: POSIX storage follows symlinks out of the storage root (read escape) #490

Closed
opened 2026-09-11 21:03:37 +00:00 by Cordy · 1 comment
Owner

Status: verified at runtime against cairnd built from bd006ef (posix driver, local auth, per-user homes). The affected code is unchanged on main (e1c855d).

What happens

A symbolic link inside the POSIX storage root is followed even when it points outside the root, and WebDAV serves the target's content.

  1. echo outside-the-storage-root > /tmp/x/outside.txt
  2. ln -s /tmp/x/outside.txt <storage.root>/home/<user>/link.txt
  3. curl -u <user>:<pw> http://127.0.0.1:<port>/dav/home/link.txtHTTP 200, body outside-the-storage-root

A PUT to the same path (204) replaced the link with a regular file rather than writing through it, so this is a read escape, not a write escape.

Where (line numbers at bd006ef)

  • internal/storage/posix/posix.go:45–56resolve is purely lexical (no Lstat / EvalSymlinks), despite the comment at :43–44; os.Stat / os.Open then follow links.
  • internal/storage/posix/posix.go:98 — listings use DirEntry.Info() (Lstat semantics) while Stat follows the link, so Depth 1 and Depth 0 disagree on type, size and ETag for the same entry.

Impact

Anyone who can place a symlink inside the storage root — a shared or exported volume, another sync tool that preserves links, shell access, an archive extraction — can read any file the cairnd process can read (its config, app-passwords.json, other users' homes) through WebDAV and every other API that opens files.

Suggested fix

Open everything through os.Root (Go ≥ 1.24; Cairn's go.mod is 1.25), which refuses to follow links out of the root — or Lstat each segment and refuse links. Decide whether links inside the root are listed at all (cairn-desktop skips them). Add a test with a link pointing outside the root.


Found while building the cairn-desktop WebDAV/tus client (phase 2, Cordy/cairn-desktop#29). Filed by Claude on behalf of @Cordy.

**Status: verified at runtime** against cairnd built from `bd006ef` (posix driver, local auth, per-user homes). The affected code is unchanged on `main` (`e1c855d`). ## What happens A symbolic link inside the POSIX storage root is followed even when it points outside the root, and WebDAV serves the target's content. 1. `echo outside-the-storage-root > /tmp/x/outside.txt` 2. `ln -s /tmp/x/outside.txt <storage.root>/home/<user>/link.txt` 3. `curl -u <user>:<pw> http://127.0.0.1:<port>/dav/home/link.txt` → **HTTP 200, body `outside-the-storage-root`** A `PUT` to the same path (204) replaced the link with a regular file rather than writing through it, so this is a read escape, not a write escape. ## Where (line numbers at `bd006ef`) - `internal/storage/posix/posix.go:45–56` — `resolve` is purely lexical (no `Lstat` / `EvalSymlinks`), despite the comment at `:43–44`; `os.Stat` / `os.Open` then follow links. - `internal/storage/posix/posix.go:98` — listings use `DirEntry.Info()` (Lstat semantics) while `Stat` follows the link, so Depth 1 and Depth 0 disagree on type, size and ETag for the same entry. ## Impact Anyone who can place a symlink inside the storage root — a shared or exported volume, another sync tool that preserves links, shell access, an archive extraction — can read any file the cairnd process can read (its config, `app-passwords.json`, other users' homes) through WebDAV and every other API that opens files. ## Suggested fix Open everything through `os.Root` (Go ≥ 1.24; Cairn's go.mod is 1.25), which refuses to follow links out of the root — or `Lstat` each segment and refuse links. Decide whether links inside the root are listed at all (cairn-desktop skips them). Add a test with a link pointing outside the root. --- Found while building the cairn-desktop WebDAV/tus client (phase 2, Cordy/cairn-desktop#29). Filed by Claude on behalf of @Cordy.
Author
Owner

Fixed in v0.6.166 (PR #501, live on the dogfood).

The posix driver is rebuilt on os.Root as suggested: every operation resolves through the kernel's beneath-only semantics, so no path — lexical or via symlink — can leave the storage root. Policy decision on in-root links (the open question in the issue): links are not data. They are never listed (List skips them), and Stat/Open/Copy on a link answer ErrNotFound, matching cairn-desktop's behaviour of skipping them. os.Root escape errors also map to ErrNotFound so nothing about the outside filesystem is leaked. The Depth 0/Depth 1 disagreement is gone as a side effect — both now use Lstat semantics.

Tests added (all witnessed red against the old driver first): out-of-root file link served-then-refused, out-of-root directory link mid-path, in-root link refused while the real file stays served. SizeByPrefix now walks via the driver's own List, so the same policy applies to the space-size sweep.

A PUT onto a link path still replaces the link with a regular file (rename over the leaf), matching what you observed — writes never pass through a link.

Fixed in **v0.6.166** (PR #501, live on the dogfood). The posix driver is rebuilt on `os.Root` as suggested: every operation resolves through the kernel's beneath-only semantics, so no path — lexical or via symlink — can leave the storage root. Policy decision on in-root links (the open question in the issue): **links are not data.** They are never listed (`List` skips them), and `Stat`/`Open`/`Copy` on a link answer `ErrNotFound`, matching cairn-desktop's behaviour of skipping them. `os.Root` escape errors also map to `ErrNotFound` so nothing about the outside filesystem is leaked. The Depth 0/Depth 1 disagreement is gone as a side effect — both now use Lstat semantics. Tests added (all witnessed red against the old driver first): out-of-root file link served-then-refused, out-of-root directory link mid-path, in-root link refused while the real file stays served. `SizeByPrefix` now walks via the driver's own `List`, so the same policy applies to the space-size sweep. A `PUT` onto a link path still replaces the link with a regular file (rename over the leaf), matching what you observed — writes never pass through a link.
Cordy closed this issue 2026-09-12 15:31:05 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#490
No description provided.