brainfs: fd-relative walk to close the directory-swap race (accepted residual from #157) #167

Open
opened 2026-10-04 06:34:49 +00:00 by mathias · 2 comments
Owner

Source: PR #166 review rounds 2–3. Accepted as a residual by Mathias on 2026-10-04. The WalkRegular doc in ingestion/internal/brainfs/brainfs.go records it.

The residual

brainfs.WalkRegular lists entries with fs.WalkDir(root.FS()). The read then reopens each entry by path through the os.Root. If a directory is swapped for a symlink to another place inside the brain between the listing and the read, the read follows it. os.Root keeps the read inside the brain, but not inside the walk root.

The reviewer measured this with a tight loop, about 200k Syncs per case:

Swapped path Symlink target Sessions text embedded
wiki/w ../sessions 10,597 times
walk root, swapped after the realDir check (in-brain) 9 times

Every race produced 0 leaks from outside the brain.

Why it was accepted

  • main had the same race, plus a static follow that #166 removes.
  • A Syncthing peer can barely hit a window of microseconds that opens once per 5 minutes.
  • A local uid-1000 process can hit it, but it can already read sessions/.
  • pgvector stores vectors, not text. Search hydrate is the reader that would surface the text.
  • The symlinkguard tripwire removes any symlink within 30 s.

Fix, when triggered

An fd-relative walk:

  1. Open each directory through the root, with O_DIRECTORY|O_NOFOLLOW or the equivalent.
  2. Verify it against the listed entry with os.SameFile.
  3. List and open children relative to that handle.

The same applies to search hydrate, which follows in-root intermediate symlinks.

Resolution triggers (any one)

  • The brain gains a Syncthing peer, or any other writer, that Mathias doesn't control.
  • sessions/, raw/ or another non-indexed brain dir holds content that uid 1000 on koala must not read.
  • The tripwire is disabled or its interval goes above 5 minutes.

Created via git-mcp on behalf of @mathias

**Source:** PR #166 review rounds 2–3. Accepted as a residual by Mathias on 2026-10-04. The WalkRegular doc in `ingestion/internal/brainfs/brainfs.go` records it. ## The residual `brainfs.WalkRegular` lists entries with `fs.WalkDir(root.FS())`. The read then reopens each entry by path through the `os.Root`. If a directory is swapped for a symlink to another place **inside the brain** between the listing and the read, the read follows it. `os.Root` keeps the read inside the brain, but not inside the walk root. The reviewer measured this with a tight loop, about 200k Syncs per case: | Swapped path | Symlink target | Sessions text embedded | |---|---|---| | `wiki/w` | `../sessions` | 10,597 times | | walk root, swapped after the realDir check | (in-brain) | 9 times | Every race produced 0 leaks from outside the brain. ## Why it was accepted - main had the same race, plus a static follow that #166 removes. - A Syncthing peer can barely hit a window of microseconds that opens once per 5 minutes. - A local uid-1000 process can hit it, but it can already read `sessions/`. - pgvector stores vectors, not text. Search hydrate is the reader that would surface the text. - The symlinkguard tripwire removes any symlink within 30 s. ## Fix, when triggered An fd-relative walk: 1. Open each directory through the root, with `O_DIRECTORY|O_NOFOLLOW` or the equivalent. 2. Verify it against the listed entry with `os.SameFile`. 3. List and open children relative to that handle. The same applies to search hydrate, which follows in-root intermediate symlinks. ## Resolution triggers (any one) - The brain gains a Syncthing peer, or any other writer, that Mathias doesn't control. - `sessions/`, `raw/` or another non-indexed brain dir holds content that uid 1000 on koala must not read. - The tripwire is disabled or its interval goes above 5 minutes. --- _Created via git-mcp on behalf of @mathias_
mathias added the risk/high label 2026-10-04 06:34:49 +00:00
Author
Owner

One more instance of the same in-brain swap residual, found while writing PR #170. brainfs.ReadRegular Lstats the final component and then opens it, and os.Root.OpenFile follows an in-root final symlink even with O_NOFOLLOW (verified on go1.27.1). So a final component swapped for an in-brain symlink between the Lstat and the open is read. The read stays inside the brain, as with the directory swap.

The fix is cheap, and PR #170 already does it for writes (WriteRegular): compare the Lstat result with the opened handle using os.SameFile, and refuse on a mismatch. Apply the same to ReadRegular/ReadRegularMax when this issue is triggered, or earlier, since it costs one comparison.


Created via git-mcp on behalf of @mathias

One more instance of the same in-brain swap residual, found while writing PR #170. `brainfs.ReadRegular` Lstats the final component and then opens it, and `os.Root.OpenFile` **follows an in-root final symlink even with O_NOFOLLOW** (verified on go1.27.1). So a final component swapped for an in-brain symlink between the Lstat and the open is read. The read stays inside the brain, as with the directory swap. The fix is cheap, and PR #170 already does it for writes (`WriteRegular`): compare the Lstat result with the opened handle using `os.SameFile`, and refuse on a mismatch. Apply the same to `ReadRegular`/`ReadRegularMax` when this issue is triggered, or earlier, since it costs one comparison. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

A related static case, from the PR #170 round-2 review (verified by execution). This issue so far covers read-side races. On write paths, a static intermediate in-brain directory symlink is also followed:

  • A directory symlink inside wiki/ or knowledge/ that points elsewhere in the brain lets brain_update and brain_tunnel rewrite an existing .md there. This was proven against raw/. Governance files and .jsonl stay unreachable.
  • If sessions/ itself is a symlink, session_log follows it out of the brain.

It needs a planted symlink, and the tripwire removes symlinks within 30 s, so the same acceptance reasoning applies. But it is a static redirect, not a race, so it is recorded here explicitly. The fix is the same: open each directory through the root and verify it is the listed directory (fd-relative walk). For writes, a cheaper interim fix would be realDir(path.Dir(rel)) before the write.


Created via git-mcp on behalf of @mathias

**A related static case, from the PR #170 round-2 review (verified by execution).** This issue so far covers read-side races. On **write** paths, a *static* intermediate in-brain directory symlink is also followed: - A directory symlink inside `wiki/` or `knowledge/` that points elsewhere in the brain lets brain_update and brain_tunnel rewrite an existing `.md` there. This was proven against `raw/`. Governance files and `.jsonl` stay unreachable. - If `sessions/` itself is a symlink, session_log follows it out of the brain. It needs a planted symlink, and the tripwire removes symlinks within 30 s, so the same acceptance reasoning applies. But it is a static redirect, not a race, so it is recorded here explicitly. The fix is the same: open each directory through the root and verify it is the listed directory (fd-relative walk). For writes, a cheaper interim fix would be `realDir(path.Dir(rel))` before the write. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#167