fix(ingestion): confine /ingest-path and brain_ingest paths to brain/raw/
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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user