- main.go's wiring was untested (the test built its own copy of the router, so dropping auth in main or re-registering POST /query as public stayed green). Routing is now one function, buildMux, which main calls and the tests call: it constructs the bearer middleware itself, registers public handlers only for patterns on a fixed allowlist (a public REST pattern would beat the authenticated catch-all by specificity), refuses an empty token, and returns an http.Handler so nothing can add a route to the result. - hyperguild CLI: PassRate sent no token and would 401. Every request now goes through brainClient.do, which adds it; the test covers Query, Write and PassRate and is named for that. - ResolveIngestPath returns one refusal for every bad path; the raw EvalSymlinks error let a caller probe which server files exist and leaked the brain directory. - PROJECT.md (and derived adapters) and the pass-rate runbook state that the REST API needs BRAIN_MCP_TOKEN and is tailnet/in-cluster. Refs #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
3.5 KiB
Go
89 lines
3.5 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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|