Both read a caller-chosen server path with extract.Text, so any file the pod could read (the ServiceAccount token, /etc/passwd) could be ingested into the brain and read back with brain_query. ResolveIngestPath resolves relative paths against brain/raw/ (the inbox the watcher already owns), evaluates symlinks, and refuses anything that lands outside. A directory ingest re-resolves every walked file, so a symlinked file inside raw/ cannot lead out. Existing ingest-path tests moved their fixtures under brain/raw/. Refs #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
2.8 KiB
Go
70 lines
2.8 KiB
Go
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")
|
|
}
|