fix(ingestion): every brain/raw/ reader goes through an os.Root; symlinks are inert
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:
@@ -0,0 +1,74 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// hyperguild#153: readers of brain/raw/ followed symlinks. /pending showed the
|
||||
// target's contents and promote would copy it into wiki/ (and brain-sync to
|
||||
// Gitea). raw/ is writable via Syncthing, so a symlink there is reachable
|
||||
// input. Every raw/ reader now treats a symlink as not-a-note.
|
||||
const sentinel153 = "SENTINEL-153-OUTSIDE"
|
||||
|
||||
func rawWithSymlink(t *testing.T) (brain string) {
|
||||
t.Helper()
|
||||
brain = t.TempDir()
|
||||
raw := filepath.Join(brain, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("---\ncreated_at: 2026-01-01T00:00:00Z\n---\n# s\n\n"+sentinel153), 0o644))
|
||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "2026-01-01-leak.md")))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-01-02-real.md"), []byte("# real\n\nreal body"), 0o644))
|
||||
return brain
|
||||
}
|
||||
|
||||
func TestListPendingSkipsSymlinkedNotes(t *testing.T) {
|
||||
notes, err := api.ListPending(rawWithSymlink(t))
|
||||
require.NoError(t, err)
|
||||
var names []string
|
||||
for _, n := range notes {
|
||||
names = append(names, n.Filename)
|
||||
assert.NotContains(t, n.Excerpt, sentinel153)
|
||||
}
|
||||
assert.Equal(t, []string{"2026-01-02-real.md"}, names)
|
||||
}
|
||||
|
||||
func TestPromoteNoteRefusesASymlinkedSource(t *testing.T) {
|
||||
brain := rawWithSymlink(t)
|
||||
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-01-01-leak.md", Wing: "testing", Hall: "facts"})
|
||||
require.Error(t, err)
|
||||
_, statErr := os.Stat(filepath.Join(brain, "wiki", "testing", "facts", "leak.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "nothing may be promoted from a symlink")
|
||||
}
|
||||
|
||||
func TestRejectNoteRefusesASymlinkedSource(t *testing.T) {
|
||||
brain := rawWithSymlink(t)
|
||||
_, err := api.RejectNote(brain, api.RejectOptions{Filename: "2026-01-01-leak.md"})
|
||||
require.Error(t, err)
|
||||
b, _ := os.ReadFile(filepath.Join(brain, "raw", "rejected", "2026-01-01-leak.md"))
|
||||
assert.False(t, strings.Contains(string(b), sentinel153))
|
||||
}
|
||||
|
||||
// Item 4: if raw/ itself is a symlink, containment inside it means nothing.
|
||||
func TestRawReadersRefuseASymlinkedRawRoot(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("# x\n\n"+sentinel153), 0o644))
|
||||
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brain, "raw")))
|
||||
|
||||
_, err := api.ResolveIngestPath(brain, "x.md")
|
||||
assert.Error(t, err, "ingest must refuse a symlinked raw/")
|
||||
notes, err := api.ListPending(brain)
|
||||
assert.Error(t, err, "pending must refuse a symlinked raw/")
|
||||
assert.Empty(t, notes)
|
||||
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "x.md", Wing: "testing", Hall: "facts"})
|
||||
assert.Error(t, err, "promote must refuse a symlinked raw/")
|
||||
}
|
||||
Reference in New Issue
Block a user