package api_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/api" ) // hyperguild#149: /ingest-path and MCP brain_ingest read a caller-chosen // server path, so any file the pod could read (the ServiceAccount token, // /etc/passwd) could be pulled into the brain and read back out. Ingest paths // now resolve inside brain/raw/ — the inbox the watcher already owns — with // symlinks evaluated, or are refused. func TestResolveIngestPathConfinesToRawInbox(t *testing.T) { brain := t.TempDir() raw := filepath.Join(brain, "raw") require.NoError(t, os.MkdirAll(filepath.Join(raw, "sub"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(raw, "sub", "ok.md"), []byte("x"), 0o644)) outside := filepath.Join(t.TempDir(), "secret.txt") require.NoError(t, os.WriteFile(outside, []byte("s"), 0o644)) require.NoError(t, os.Symlink(outside, filepath.Join(raw, "link.md"))) require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(raw, "dirlink"))) for _, ok := range []string{filepath.Join(raw, "sub", "ok.md"), "sub/ok.md", raw, "sub"} { _, err := api.ResolveIngestPath(brain, ok) assert.NoError(t, err, ok) } for _, bad := range []string{ "/etc/hostname", outside, "../secret.txt", filepath.Join(raw, "..", "wiki"), filepath.Join(raw, "link.md"), filepath.Join(raw, "dirlink", "secret.txt"), raw + "-evil/x.md", "", } { _, err := api.ResolveIngestPath(brain, bad) assert.Error(t, err, "must refuse %q", bad) } } func TestIngestPathRefusesAPathOutsideTheInbox(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{"path": "/etc/hostname"}) rec := httptest.NewRecorder() h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "raw") } // A directory ingest must not follow a symlinked FILE inside raw/ out of it. func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) { dir, h := setup(t) raw := filepath.Join(dir, "raw", "batch") require.NoError(t, os.MkdirAll(raw, 0o755)) outside := filepath.Join(t.TempDir(), "secret.md") require.NoError(t, os.WriteFile(outside, []byte("# secret\n\nSENTINEL-149-OUTSIDE"), 0o644)) require.NoError(t, os.Symlink(outside, filepath.Join(raw, "leak.md"))) body, _ := json.Marshal(map[string]any{"path": "batch", "source": "walk"}) rec := httptest.NewRecorder() h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))) assert.Contains(t, rec.Body.String(), "skipped") assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE") } // PR #152 review: the raw EvalSymlinks error told an authenticated caller // whether any server file existed ("no such file") or was unreadable // ("permission denied"), and leaked the brain directory. Every refusal now // reads the same. func TestResolveIngestPathRefusalsAreIndistinguishable(t *testing.T) { brain := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755)) var msgs []string for _, p := range []string{"/etc/hostname", "/etc/definitely-not-here-149", "../x", "nope/missing.md", ""} { _, err := api.ResolveIngestPath(brain, p) require.Error(t, err, p) msgs = append(msgs, err.Error()) assert.NotContains(t, err.Error(), brain, "refusal for %q leaks the brain dir", p) } for _, m := range msgs[1:] { assert.Equal(t, msgs[0], m) } }