Files
summercms/modules/pier/endpoint_guards_test.go
Jakub Zych 0b8e027b81 test(19.7-05): security review pins for the MCP endpoint's outer layers
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
  read, a body over the endpoint cap is 413, a long GET stream ends once
  its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
  refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
  demo's deploy check (T-19.7-37)
2026-10-23 14:26:26 +02:00

110 lines
3.7 KiB
Go

package pier_test
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestEndpointGuards (T-19.7-18, T-19.7-19, Pitfall 10): the endpoint's own
// outer layers. A browser Origin other than the allowed ones is 403 before
// the bearer or the body is read (no Origin and the app's own origin pass);
// a request body above the endpoint's cap is 413; and a long-lived GET
// stream re-verifies its bearer and ends once the grant no longer verifies.
func TestEndpointGuards(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_stream": grant(12, "c6", 6, "read")})
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), acmePlugin{tools: []pact.MCPTool{echoTool()}})
post := func(token, origin string, body io.Reader) *http.Response {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, in.url(), body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
return res
}
t.Run("origin check", func(t *testing.T) {
if res := post("", "https://evil.example", strings.NewReader(initializeBody)); res.StatusCode != http.StatusForbidden {
t.Fatalf("foreign origin without a bearer = %d, want 403 before the bearer check", res.StatusCode)
}
if res := post("oas_at_test", "https://evil.example", strings.NewReader(initializeBody)); res.StatusCode != http.StatusForbidden {
t.Fatalf("foreign origin = %d", res.StatusCode)
}
if res := post("oas_at_test", "http://acme.test", strings.NewReader(initializeBody)); res.StatusCode != http.StatusOK {
t.Fatalf("the app's own origin = %d", res.StatusCode)
}
if res := post("oas_at_test", "", strings.NewReader(initializeBody)); res.StatusCode != http.StatusOK {
t.Fatalf("no origin = %d", res.StatusCode)
}
})
t.Run("body cap", func(t *testing.T) {
big := bytes.Repeat([]byte(" "), 5<<20)
body := append([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), big...)
if res := post("oas_at_test", "", bytes.NewReader(body)); res.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("5 MiB body = %d, want 413", res.StatusCode)
}
})
t.Run("stream re-verifies its bearer", func(t *testing.T) {
in.pier().SetRecheck(50 * time.Millisecond)
sid := openSession(t, ctx, in, "oas_at_stream")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.url(), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer oas_at_stream")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Mcp-Session-Id", sid)
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("GET stream = %d", res.StatusCode)
}
ended := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, res.Body)
close(ended)
}()
select {
case <-ended:
t.Fatal("the stream ended while the grant still verified")
case <-time.After(300 * time.Millisecond):
}
verifier.revoke("oas_at_stream")
select {
case <-ended:
case <-time.After(5 * time.Second):
t.Fatal("the stream outlived its revoked grant")
}
})
}