From 99fedeaa984bf5cc3af64c0f1da1463750cddec4 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 16:17:45 +0200 Subject: [PATCH] fix(ingestion): authenticate the REST API as a whole, not route by route Only /mcp was wrapped in BearerMiddleware; /query, /write, /ingest*, /promote, /pending and the rest on the same mux had no auth, and infra#517 found them on the internet (mitigated there by narrowing the ingress). They are still open on the tailnet and LAN NodePort 30330. mountREST puts the REST routes on their own mux, mounted at "/" behind the same bearer middleware and credential as /mcp. Routes registered on the outer mux stay explicitly public (/mcp has its own auth, /capture, the HMAC webhook, OAuth metadata, /metrics); anything else, including a route added later, is authenticated by default. The hyperguild CLI (cmd/hyperguild and internal/brain) now sends BRAIN_MCP_TOKEN, which it never did. brain-ui and brain-ingest-ntfy already send theirs. Refs #149 Co-Authored-By: Claude Opus 5.5 --- cmd/hyperguild/http.go | 11 +++++ cmd/hyperguild/http_auth_test.go | 50 +++++++++++++++++++ ingestion/cmd/server/main.go | 18 +++---- ingestion/cmd/server/routes.go | 30 ++++++++++++ ingestion/cmd/server/routes_test.go | 74 +++++++++++++++++++++++++++++ internal/brain/client.go | 5 ++ internal/brain/client_test.go | 15 ++++++ 7 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 cmd/hyperguild/http_auth_test.go create mode 100644 ingestion/cmd/server/routes.go create mode 100644 ingestion/cmd/server/routes_test.go diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go index b6e9b12..1205c1e 100644 --- a/cmd/hyperguild/http.go +++ b/cmd/hyperguild/http.go @@ -20,6 +20,7 @@ const defaultBrainURL = "http://koala:30330" // shell-friendly client; MCP framing is unnecessary. type brainClient struct { baseURL string + token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149 http *http.Client } @@ -30,6 +31,7 @@ func newBrainClient() *brainClient { } return &brainClient{ baseURL: u, + token: os.Getenv("BRAIN_MCP_TOKEN"), http: &http.Client{Timeout: 5 * time.Second}, } } @@ -62,6 +64,7 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") + c.authorize(req) resp, err := c.http.Do(req) if err != nil { @@ -105,6 +108,7 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") + c.authorize(req) resp, err := c.http.Do(req) if err != nil { @@ -158,3 +162,10 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass } return &out, nil } + +// authorize adds the bearer token when one is configured. +func (c *brainClient) authorize(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } +} diff --git a/cmd/hyperguild/http_auth_test.go b/cmd/hyperguild/http_auth_test.go new file mode 100644 index 0000000..97a8e56 --- /dev/null +++ b/cmd/hyperguild/http_auth_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI +// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call. +func TestBrainClientSendsBrainMCPToken(t *testing.T) { + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.URL.Path+" "+r.Header.Get("Authorization")) + if r.URL.Path == "/write" { + _, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`)) + return + } + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token") + + c := newBrainClient() + _, err := c.Query(context.Background(), "x", 1) + require.NoError(t, err) + _, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y")) + require.NoError(t, err) + assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token"}, got) +} + +func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + t.Setenv("BRAIN_MCP_TOKEN", "") + _, err := newBrainClient().Query(context.Background(), "x", 1) + require.NoError(t, err) + assert.Empty(t, auth) +} diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index bc38f11..b0193d0 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -504,17 +504,6 @@ func main() { } mux := http.NewServeMux() - mux.HandleFunc("POST /query", h.Query) - mux.HandleFunc("POST /write", h.Write) - mux.HandleFunc("POST /index", h.Index) - mux.HandleFunc("POST /ingest", h.Ingest) - mux.HandleFunc("POST /ingest-path", h.IngestPath) - mux.HandleFunc("POST /ingest-raw", h.IngestRaw) - mux.HandleFunc("POST /backfill-refs", h.BackfillRefs) - mux.HandleFunc("GET /pending", h.Pending) - mux.HandleFunc("POST /promote", h.Promote) - mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) - mux.HandleFunc("GET /pass-rate", h.PassRate) if webhookHandler != nil { mux.Handle("POST /webhooks/brain-sync", webhookHandler) } @@ -532,6 +521,13 @@ func main() { } mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)) + // The REST API, authenticated as a whole (hyperguild#149). Same credential + // as /mcp: callers (brain-ui, brain-ingest-ntfy, the hyperguild CLI) send + // BRAIN_MCP_TOKEN. Registered after the public routes on purpose only for + // reading order; ServeMux precedence is by pattern specificity. + mountREST(mux, h, func(next http.Handler) http.Handler { + return chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, next) + }) // POST /capture (#53/#54): the uniform capture REST door. Needs a ticket // tracker to file action items, so it only mounts when Gitea is diff --git a/ingestion/cmd/server/routes.go b/ingestion/cmd/server/routes.go new file mode 100644 index 0000000..b4584db --- /dev/null +++ b/ingestion/cmd/server/routes.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" +) + +// mountREST registers the REST API on its own mux and mounts it at "/" behind +// auth (hyperguild#149). Authentication is a property of the mux, not of each +// route: anything not registered on the outer mux as explicitly public (/mcp, +// /capture, the HMAC webhook, OAuth metadata, /metrics) lands here and is +// authenticated, including routes added later. Before this, only /mcp was +// wrapped, and the REST routes beside it were open; infra#517 found them on +// the internet. +func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) { + rest := http.NewServeMux() + rest.HandleFunc("POST /query", h.Query) + rest.HandleFunc("POST /write", h.Write) + rest.HandleFunc("POST /index", h.Index) + rest.HandleFunc("POST /ingest", h.Ingest) + rest.HandleFunc("POST /ingest-path", h.IngestPath) + rest.HandleFunc("POST /ingest-raw", h.IngestRaw) + rest.HandleFunc("POST /backfill-refs", h.BackfillRefs) + rest.HandleFunc("GET /pending", h.Pending) + rest.HandleFunc("POST /promote", h.Promote) + rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) + rest.HandleFunc("GET /pass-rate", h.PassRate) + mux.Handle("/", auth(rest)) +} diff --git a/ingestion/cmd/server/routes_test.go b/ingestion/cmd/server/routes_test.go new file mode 100644 index 0000000..4e24265 --- /dev/null +++ b/ingestion/cmd/server/routes_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" +) + +const routesTok = "routes-test-token" + +// hyperguild#149: only /mcp was authenticated; every REST route on the same +// mux was open, and infra#517 found them on the internet. The REST API is now +// mounted at "/" behind the bearer middleware, so any route not registered +// as explicitly public is authenticated, including routes added later. +func testRouter(t *testing.T) http.Handler { + t.Helper() + h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{}) + mux := http.NewServeMux() + mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mountREST(mux, h, func(next http.Handler) http.Handler { + return chassisauth.BearerMiddleware(routesTok, nil, "brain", "", next) + }) + return mux +} + +func do(t *testing.T, h http.Handler, method, path, authz string) int { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`)) + if authz != "" { + req.Header.Set("Authorization", authz) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code +} + +func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) { + r := testRouter(t) + for _, rt := range []struct{ method, path string }{ + {"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"}, + {"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"}, + {"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"}, + } { + assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, ""), "%s %s without a token", rt.method, rt.path) + assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, "Bearer wrong"), "%s %s with a wrong token", rt.method, rt.path) + } +} + +func TestRESTRoutesServeAValidToken(t *testing.T) { + r := testRouter(t) + assert.NotEqual(t, http.StatusUnauthorized, do(t, r, "POST", "/query", "Bearer "+routesTok)) + assert.Equal(t, http.StatusOK, do(t, r, "GET", "/pending", "Bearer "+routesTok)) +} + +// Default-deny: a path nobody registered is still behind auth, so a route +// added to the REST mux later cannot ship open by omission. +func TestUnregisteredPathIsAuthenticatedByDefault(t *testing.T) { + assert.Equal(t, http.StatusUnauthorized, do(t, testRouter(t), "POST", "/some-future-route", "")) +} + +// Explicitly public routes registered on the outer mux keep working. +func TestMetricsStaysOpenForPrometheus(t *testing.T) { + assert.Equal(t, http.StatusOK, do(t, testRouter(t), "GET", "/metrics", "")) +} diff --git a/internal/brain/client.go b/internal/brain/client.go index 3a5e6e8..8ccbc0b 100644 --- a/internal/brain/client.go +++ b/internal/brain/client.go @@ -13,6 +13,7 @@ import ( "io" "log/slog" "net/http" + "os" "strings" ) @@ -42,6 +43,10 @@ func Query(ctx context.Context, baseURL, query string, limit int) (string, error return "", nil } req.Header.Set("Content-Type", "application/json") + // The REST API requires a bearer token since hyperguild#149. + if tok := os.Getenv("BRAIN_MCP_TOKEN"); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/brain/client_test.go b/internal/brain/client_test.go index fb11b98..1936744 100644 --- a/internal/brain/client_test.go +++ b/internal/brain/client_test.go @@ -65,3 +65,18 @@ func TestQueryUnavailableServerReturnsEmpty(t *testing.T) { require.NoError(t, err) assert.Empty(t, result) } + +// hyperguild#149: the REST API requires a bearer token. +func TestQuerySendsBrainMCPToken(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_MCP_TOKEN", "lib-test-token") + _, _ = brain.Query(context.Background(), srv.URL, "x", 1) + if auth != "Bearer lib-test-token" { + t.Fatalf("Authorization = %q, want the bearer token", auth) + } +}