A site root that is itself a symlink passes the escape check and publishes its target #61

Closed
opened 2026-09-01 14:42:29 +01:00 by vnprc-agent · 1 comment

Refuse a site root that is itself a symlink, so the escape check cannot be bypassed by moving the link up one level.

The defect

checks/site-symlink-escape.sh refuses a symlink inside the site root but accepts a root that is a symlink pointing outside the store. Caddy's file server resolves symlinks at request time against the running host, so such a root publishes the linked directory from a public web root — the leak the script's own header says it exists to stop.

Three things combine:

  • Line 38 resolves the root with readlink -f into site_root_real.
  • Line 45's find "$site_root" -type l reports the root itself when the root is a symlink.
  • Line 53 compares each found link's resolved target against site_root_real, and for the root entry those are equal by construction, so it is classified as inside.

The [[ ! -d $site_root ]] guard on line 33 does not catch it either, because -d follows the link.

Reproduction

mkdir -p real && ln -s /etc root-is-link
ln -s /etc real/leak

SITE_ROOT=$PWD/root-is-link bash checks/site-symlink-escape.sh   # exit 0  -- accepted
SITE_ROOT=$PWD/real         bash checks/site-symlink-escape.sh   # exit 1  -- correctly refused

The second case is the one the fixtures cover; the first is not covered anywhere.

Why the other guards do not stop it

types.pathInStore is lexical — path concatenation normalises .. before the store-prefix test, but nothing resolves symlinks — so a caller setting siteRoot = "${theirDerivation}/public" where public is a symlink to /etc satisfies it. The string carries store context, so the assertion in modules/static-site.nix is satisfied too. nix/build-zola-site.nix does not apply: it scans the source tree of a site it builds itself and its output is always a real directory, whereas this check exists precisely for roots that builder never produced. checks/static-site.nix tests lexical ../.. traversal only.

The result is that a derivation whose output is ln -s /etc $out, or any generator emitting a symlinked subdirectory, passes the whole system build and serves the target publicly. README.md's claim that the system build refuses a symlink escaping the root is currently false for this shape.

Fix shape

Refuse when the root does not resolve to itself — readlink -f "$site_root" differing from "$site_root" — or require [[ ! -L $site_root ]] before the scan. Either way the fixture suite needs a case for a symlinked root, since its absence is why this survived.

How it was found

An adversarial review pass over the merged files, asked specifically whether a symlink could get past both the build-time refusal and the run-time one. Reproduced independently before filing.

Refuse a site root that is itself a symlink, so the escape check cannot be bypassed by moving the link up one level. ### The defect `checks/site-symlink-escape.sh` refuses a symlink *inside* the site root but accepts a root that *is* a symlink pointing outside the store. Caddy's file server resolves symlinks at request time against the running host, so such a root publishes the linked directory from a public web root — the leak the script's own header says it exists to stop. Three things combine: - Line 38 resolves the root with `readlink -f` into `site_root_real`. - Line 45's `find "$site_root" -type l` reports the root itself when the root is a symlink. - Line 53 compares each found link's resolved target against `site_root_real`, and for the root entry those are equal by construction, so it is classified as inside. The `[[ ! -d $site_root ]]` guard on line 33 does not catch it either, because `-d` follows the link. ### Reproduction ``` mkdir -p real && ln -s /etc root-is-link ln -s /etc real/leak SITE_ROOT=$PWD/root-is-link bash checks/site-symlink-escape.sh # exit 0 -- accepted SITE_ROOT=$PWD/real bash checks/site-symlink-escape.sh # exit 1 -- correctly refused ``` The second case is the one the fixtures cover; the first is not covered anywhere. ### Why the other guards do not stop it `types.pathInStore` is lexical — path concatenation normalises `..` before the store-prefix test, but nothing resolves symlinks — so a caller setting `siteRoot = "${theirDerivation}/public"` where `public` is a symlink to `/etc` satisfies it. The string carries store context, so the assertion in `modules/static-site.nix` is satisfied too. `nix/build-zola-site.nix` does not apply: it scans the source tree of a site it builds itself and its output is always a real directory, whereas this check exists precisely for roots that builder never produced. `checks/static-site.nix` tests lexical `../..` traversal only. The result is that a derivation whose output is `ln -s /etc $out`, or any generator emitting a symlinked subdirectory, passes the whole system build and serves the target publicly. `README.md`'s claim that the system build refuses a symlink escaping the root is currently false for this shape. ### Fix shape Refuse when the root does not resolve to itself — `readlink -f "$site_root"` differing from `"$site_root"` — or require `[[ ! -L $site_root ]]` before the scan. Either way the fixture suite needs a case for a symlinked root, since its absence is why this survived. ### How it was found An adversarial review pass over the merged files, asked specifically whether a symlink could get past both the build-time refusal and the run-time one. Reproduced independently before filing.
Author

Independently reproduced by a second review pass using a different model, which described the same three-part mechanism: find reports the root itself when the root is a symlink, and comparing that entry's resolved target against the resolved root makes them equal by construction. Two reviewers reaching the same finding from different starting points is the strongest evidence available that it is real rather than an artefact of one model's reading. The remaining findings from both passes are filed separately; this one is the only one with a security consequence.

Independently reproduced by a second review pass using a different model, which described the same three-part mechanism: `find` reports the root itself when the root is a symlink, and comparing that entry's resolved target against the resolved root makes them equal by construction. Two reviewers reaching the same finding from different starting points is the strongest evidence available that it is real rather than an artefact of one model's reading. The remaining findings from both passes are filed separately; this one is the only one with a security consequence.
vnprc closed this issue 2026-09-02 01:46:50 +01:00
Sign in to join this conversation.
No milestone
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
allod/archetypes#61
No description provided.