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>
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ErrOutsideIngestRoot is the one refusal for every bad ingest path. It never
|
|
// carries the underlying error: "no such file" versus "permission denied"
|
|
// would let a caller probe which server files exist (PR #152 review).
|
|
var ErrOutsideIngestRoot = errors.New("ingest path must be an existing file or directory inside brain/raw/")
|
|
|
|
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
|
|
// inbox the watcher already owns (hyperguild#149). Relative paths resolve
|
|
// against that root; symlinks are evaluated before the containment check, so
|
|
// a link inside raw/ that points elsewhere is refused. The path must exist.
|
|
func ResolveIngestPath(brainDir, p string) (string, error) {
|
|
if strings.TrimSpace(p) == "" {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
// raw/ itself must be a real directory: if it were a symlink, containment
|
|
// inside it would mean nothing (hyperguild#153 item 4).
|
|
if fi, lerr := os.Lstat(filepath.Join(brainDir, "raw")); lerr != nil || fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
|
if err != nil {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
if !filepath.IsAbs(p) {
|
|
p = filepath.Join(root, p)
|
|
}
|
|
real, err := filepath.EvalSymlinks(filepath.Clean(p))
|
|
if err != nil {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
rel, err := filepath.Rel(root, real)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
return real, nil
|
|
}
|
|
|
|
// ReadIngestFile extracts text from an ingest path already confined by
|
|
// ResolveIngestPath, reading it through the raw/ root rather than reopening
|
|
// the path by name (hyperguild#153 item 3): if the file is swapped for a
|
|
// symlink after the check, the read still cannot leave raw/.
|
|
func ReadIngestFile(brainDir, resolved string) (string, error) {
|
|
root, rawPath, err := OpenRawRoot(brainDir)
|
|
if err != nil {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
realRoot, err := filepath.EvalSymlinks(rawPath)
|
|
if err != nil {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
rel, err := filepath.Rel(realRoot, resolved)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
return "", ErrOutsideIngestRoot
|
|
}
|
|
return extract.TextIn(root, rel)
|
|
}
|