fix(ingestion): every brain/raw/ reader goes through an os.Root; symlinks are inert
CI / Lint / Test / Vet (pull_request) Successful in 34s
CI / Mirror to GitHub (pull_request) Skipped

raw/ is writable via Syncthing (a sendreceive folder shared with
flamingo, symlinks synced) and by the brain CronJobs, and several
readers followed symlinks out of it:

- /pending and brain_pending showed a symlink target's contents;
  brain_promote would copy it into wiki/ (then brain-sync to Gitea);
  brain_reject would copy it into raw/rejected/.
- The watcher, which needs no API call at all, ingested symlinked files
  (WalkDir yields them; extract.Text followed them). Not in the issue;
  found while fixing it.
- Ingest checked a path then reopened it by name (TOCTOU).

OpenRawRoot opens brain/raw as an os.Root and refuses a raw/ that is
itself a symlink. ListPending, PromoteNote and RejectNote read through
it and refuse anything not a regular file. extract.TextIn reads through
a root (temp copy for pdf/docmark); /ingest-path, brain_ingest and the
watcher use it, and the watcher archives via the root too, so a file
swapped for a symlink after a check still cannot lead out.

Refs #153

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:47:43 +02:00
co-authored by Claude Opus 5.5
parent cfe3f24e25
commit 8a91feb02b
10 changed files with 372 additions and 51 deletions
+59
View File
@@ -0,0 +1,59 @@
package api
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// errNotANote is returned for a raw/ entry that is not a regular file — in
// particular a symlink, whose target may be anywhere (hyperguild#153).
var errNotANote = errors.New("not a regular note file in raw/ (symlinks are refused)")
// errRawNotDir means brain/raw is not a real directory (e.g. a symlink), so
// containment inside it would mean nothing (hyperguild#153 item 4).
var errRawNotDir = errors.New("brain/raw is not a real directory")
// OpenRawRoot opens brain/raw as an os.Root, refusing a raw/ that is itself a
// symlink. Reads through the root cannot leave it, even if an entry is
// swapped for a symlink between a check and the read. It returns the root
// and raw/'s path. A missing raw/ returns an error wrapping os.ErrNotExist.
func OpenRawRoot(brainDir string) (*os.Root, string, error) {
p := filepath.Join(brainDir, "raw")
fi, err := os.Lstat(p)
if err != nil {
return nil, "", err
}
if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
return nil, "", errRawNotDir
}
r, err := os.OpenRoot(p)
if err != nil {
return nil, "", fmt.Errorf("open raw root: %w", err)
}
return r, p, nil
}
// readRawNote reads one raw/ entry by basename through the root, refusing
// anything that is not a regular file.
func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
fi, err := r.Lstat(base)
if err != nil {
return nil, nil, err
}
if !fi.Mode().IsRegular() {
return nil, nil, errNotANote
}
f, err := r.Open(base)
if err != nil {
return nil, nil, err
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(f)
if err != nil {
return nil, nil, err
}
return b, fi, nil
}