Files
summercms/modules/pier/dispatch_hardening_test.go
Jakub Zych ccf77b9103 test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07),
  TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06),
  TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01,
  A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields
  (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01),
  TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress
  (B-IN-04); test seam WrapStore
- pact TestFrontendTokenScopes, towel TestClientIP, cabana
  TestTokenScopeNameParity
- cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation
  (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the
  Postgres TestAccountTokenRevokeEvent (B-WR-04) and
  TestResolveBackendTokenRemote (B-IN-04)
- vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended
  AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01)
- check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS,
  DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
2026-10-23 18:36:16 +02:00

208 lines
8.2 KiB
Go

package pier_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// dispatchGuard is a fakeGuard whose MCPDispatch answer is given per
// route method.
type dispatchGuard struct {
*fakeGuard
byMethod func(pact.MCPSubject, string) (pact.MCPDispatch, error)
}
func (g dispatchGuard) MCPDispatch(_ context.Context, s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
return g.byMethod(s, method)
}
type oneSegmentInput struct {
ID string `json:"id" path:"id"`
}
type manySegmentsInput struct {
Rest string `json:"rest" path:"rest"`
}
type numberInput struct {
ID int64 `json:"id" path:"id"`
N int64 `json:"n,omitempty" query:"n"`
}
// TestDispatchHardening pins plan 06's dispatch hardening (A-WR-01, the
// runtime half of A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08): path values
// the mux never delivers (., .., empty segments, a slash in a single
// segment) are refused before the route; a missing path value is refused;
// a provider error answers a fixed text and its message reaches only the
// log; a large integer keeps its digits in the path and the query; the
// inner request carries the outer Host and forwarding headers but never the
// outer credentials; and a provider whose answer contradicts the tool's
// guard is refused.
func TestDispatchHardening(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var hits atomic.Int64
seen := func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"),
"rest": r.PathValue("rest"),
"n": r.URL.Query().Get("n"),
"host": r.Host,
"proto": r.Header.Get("X-Forwarded-Proto"),
"fhost": r.Header.Get("X-Forwarded-Host"),
"auth": r.Header.Get("Authorization"),
"cookie": r.Header.Get("Cookie"),
})
}
hard := routePlugin{id: "test.hard",
routes: []testRoute{
{method: http.MethodPost, path: "/hard/one/{id}", mw: []string{"acme.auth"}, h: seen},
{method: http.MethodGet, path: "/hard/many/{rest...}", mw: []string{"acme.auth"}, h: seen},
{method: http.MethodGet, path: "/hard/num/{id}", mw: []string{"acme.auth"}, h: seen},
},
tools: []pact.MCPTool{
{Name: "one", Method: "POST", Path: "/hard/one/{id}", Input: oneSegmentInput{}},
{Name: "many", Method: "GET", Path: "/hard/many/{rest...}", Input: manySegmentsInput{}},
{Name: "num", Method: "GET", Path: "/hard/num/{id}", Input: numberInput{}},
}}
// One frontend guard provider (an OAuth grant acts under exactly one);
// its dispatch answer depends on the route's method: PUT fails like a
// database error, PATCH names the backend guard, DELETE answers a
// backend principal.
pgxLike := "ERROR: relation \"backend_users\" does not exist (SQLSTATE 42P01)"
guard := dispatchGuard{fakeGuard: &fakeGuard{}}
guard.byMethod = func(s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
switch method {
case http.MethodPut:
return pact.MCPDispatch{}, errors.New(pgxLike)
case http.MethodPatch:
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"backend"}}, nil
case http.MethodDelete:
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{"acme"}}, nil
}
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"acme"}}, nil
}
hard.routes = append(hard.routes,
testRoute{method: http.MethodPut, path: "/hard/broken", mw: []string{"acme.auth"}, h: seen},
testRoute{method: http.MethodPatch, path: "/hard/claims-backend", mw: []string{"acme.auth"}, h: seen},
testRoute{method: http.MethodDelete, path: "/hard/backend-principal", mw: []string{"acme.auth"}, h: seen},
)
hard.tools = append(hard.tools,
pact.MCPTool{Name: "broken", Method: "PUT", Path: "/hard/broken", Input: struct{}{}},
pact.MCPTool{Name: "claims_backend", Method: "PATCH", Path: "/hard/claims-backend", Input: struct{}{}},
pact.MCPTool{Name: "backend_principal", Method: "DELETE", Path: "/hard/backend-principal", Input: struct{}{}},
)
app := newApp(t, appSpec{guards: []pact.MCPGuard{guard}})
in := startInstance(t, app, hard)
t.Cleanup(in.pier().StopReaper)
outer, err := url.Parse(in.srv.URL)
if err != nil {
t.Fatal(err)
}
logs := captureLogs(t)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
"Cookie": "summer_session=outer",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "site.example",
}})
call := func(name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
refused := func(t *testing.T, name string, args map[string]any, want string) {
t.Helper()
before := hits.Load()
res := call(name, args)
if !res.IsError || !strings.Contains(resultText(t, res), want) {
t.Fatalf("%s %v = %+v, want a tool error with %q", name, args, res, want)
}
if hits.Load() != before {
t.Fatalf("%s %v reached the route", name, args)
}
}
t.Run("path values the mux never delivers are refused", func(t *testing.T) {
for _, v := range []string{"..", "."} {
refused(t, "hard_one", map[string]any{"id": v}, "must not contain empty, . or .. segments")
}
refused(t, "hard_one", map[string]any{"id": ""}, "one non-empty path segment")
refused(t, "hard_one", map[string]any{"id": "a/b"}, "one non-empty path segment")
for _, v := range []string{"a/../b", "a//b", "./a", "a/.."} {
refused(t, "hard_many", map[string]any{"rest": v}, "must not contain empty, . or .. segments")
}
if res := call("hard_many", map[string]any{"rest": "a/b"}); res.IsError || structured(t, res)["rest"] != "a/b" {
t.Fatalf("a/b on a {rest...} route = %+v", res)
}
})
t.Run("a missing path value is refused", func(t *testing.T) {
refused(t, "hard_one", map[string]any{}, "invalid arguments")
})
t.Run("a provider error answers the fixed text and is only logged", func(t *testing.T) {
before := hits.Load()
res := call("hard_broken", nil)
if !res.IsError || resultText(t, res) != "pier: the tool could not be run for this caller" {
t.Fatalf("provider error = %+v %q", res, resultText(t, res))
}
if strings.Contains(resultText(t, res), "SQLSTATE") {
t.Fatal("the provider's message reached the agent")
}
if hits.Load() != before {
t.Fatal("a failed dispatch reached the route")
}
if !strings.Contains(logs.String(), "42P01") || !strings.Contains(logs.String(), "guard dispatch failed") {
t.Fatalf("the provider's message is not in the log: %s", logs.String())
}
})
t.Run("a large integer keeps its digits in the path and the query", func(t *testing.T) {
res := cs.CallTool
raw := json.RawMessage(`{"id":9007199254740993,"n":9007199254740993}`)
out, err := res(ctx, &mcp.CallToolParams{Name: "hard_num", Arguments: raw})
if err != nil {
t.Fatal(err)
}
got := structured(t, out)
if out.IsError || got["id"] != "9007199254740993" || got["n"] != "9007199254740993" {
t.Fatalf("large integer = %+v", got)
}
})
t.Run("the inner request has the outer Host and forwarding headers, never credentials", func(t *testing.T) {
got := structured(t, call("hard_num", map[string]any{"id": 5}))
if got["host"] != outer.Host || got["proto"] != "https" || got["fhost"] != "site.example" {
t.Fatalf("inner request host %v proto %v forwarded host %v, want %s https site.example", got["host"], got["proto"], got["fhost"], outer.Host)
}
if got["auth"] != "" || got["cookie"] != "" {
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
}
})
t.Run("a dispatch contradicting the tool's guard is refused", func(t *testing.T) {
for _, name := range []string{"hard_claims_backend", "hard_backend_principal"} {
refused(t, name, nil, "pier: the tool could not be run for this caller")
}
if !strings.Contains(logs.String(), "contradicts the tool's guard") {
t.Fatalf("no contradiction line: %s", logs.String())
}
})
}