Files
summercms/modules/pier/dispatch.go
Jakub Zych 104db9959f fix(pier): refuse path values the mux never delivers and harden dispatch
- a path field must be required in the reflected schema (boot error), and
  a missing value, an empty, . or .. segment, or a / inside one segment is
  a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
  only an invalid token and an unknown or deactivated account keep their
  own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
  keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
  X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
  guard is refused (A-IN-08)
2026-10-23 16:05:32 +02:00

417 lines
13 KiB
Go

package pier
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// resultTooLarge is the text of the tool error for a route answer above
// mcp.max_result_bytes.
const resultTooLarge = "pier: the route's answer exceeds mcp.max_result_bytes"
// dispatchFailed is the fixed text of the tool error for a guard dispatch
// that failed or contradicted the tool's guard; the cause (which may name a
// database host or user) is logged server-side only (A-WR-08).
const dispatchFailed = "pier: the tool could not be run for this caller"
// tokenInvalid is the tool error text for a provider answering
// pact.ErrMCPTokenInvalid.
const tokenInvalid = "pier: the caller's token is no longer valid"
// callInfo is what a dispatched call copies from the outer MCP request: the
// client address, the Host, the forwarding, real-ip and language headers,
// plus the session the call belongs to, for the audit line.
type callInfo struct {
remoteAddr string
host string
forwardedFor string
forwardedProto string
forwardedHost string
realIP string
acceptLanguage string
session string
}
// callInfoFrom reads callInfo off an HTTP request.
func callInfoFrom(r *http.Request) callInfo {
if r == nil {
return callInfo{}
}
return callInfo{
remoteAddr: r.RemoteAddr,
host: r.Host,
forwardedFor: r.Header.Get("X-Forwarded-For"),
forwardedProto: r.Header.Get("X-Forwarded-Proto"),
forwardedHost: r.Header.Get("X-Forwarded-Host"),
realIP: r.Header.Get("X-Real-IP"),
acceptLanguage: r.Header.Get("Accept-Language"),
}
}
// dispatcher runs tool calls in-process through the host's compiled route
// chains (D-10, D-11, D-13, D-17).
type dispatcher struct {
host Host
app *backpack.App
cfg Config
}
// call validates the arguments against the tool's resolved schema, splits
// them into path values, query string and body, builds the inner request
// without any Authorization or Cookie header, attaches the bouncer dispatch
// marker with the guard's pre-resolved principal, serves the compiled chain
// into a size-capped recorder and maps the answer: 2xx to text plus
// structured content, anything else to a tool error carrying the route's
// own body. Protocol-level failures (a tool the identity may not call, an
// unknown route) are tool errors too, so the agent can read them.
func (d *dispatcher) call(ctx context.Context, id Identity, info callInfo, t *tool, raw json.RawMessage) *mcp.CallToolResult {
start := time.Now()
res, status := d.run(ctx, id, info, t, raw)
logToolCall(logger(), id, t, info.session, status, time.Since(start))
return res
}
func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *tool, raw json.RawMessage) (*mcp.CallToolResult, int) {
// tools/call may omit arguments (or send null) for a tool without
// required input; both mean an empty object.
if trimmed := bytes.TrimSpace(raw); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
raw = json.RawMessage("{}")
}
var parts map[string]json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
return toolError("pier: arguments must be a JSON object: " + err.Error()), 0
}
var instance any
if err := json.Unmarshal(raw, &instance); err != nil {
return toolError("pier: arguments must be a JSON object: " + err.Error()), 0
}
if err := t.resolved.Validate(instance); err != nil {
return toolError("pier: invalid arguments: " + err.Error()), 0
}
pathValues := map[string]string{}
query := url.Values{}
for _, f := range t.fields {
v, ok := parts[f.name]
if !ok {
continue
}
switch f.kind {
case argPath:
s, err := scalarString(v)
if err != nil {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must be a string or a number", f.name)), 0
}
if s == "" || (strings.Contains(s, "/") && !strings.Contains(t.pattern, "{"+f.key+"...}")) {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must be one non-empty path segment", f.name)), 0
}
// The mux cleans a request path before any route sees it, so a
// route never receives an empty, . or .. segment (A-WR-01).
for _, seg := range strings.Split(s, "/") {
if seg == "" || seg == "." || seg == ".." {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must not contain empty, . or .. segments", f.name)), 0
}
}
pathValues[f.key] = s
delete(parts, f.name)
case argQuery:
values, err := queryStrings(v)
if err != nil {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must be a scalar or a list of scalars", f.name)), 0
}
for _, s := range values {
query.Add(f.key, s)
}
delete(parts, f.name)
}
}
// Every {segment} gets a value, as over HTTP (A-WR-02; boot already
// requires path fields, this guards the dispatch on its own).
for _, f := range t.fields {
if _, ok := pathValues[f.key]; f.kind == argPath && !ok {
return toolError(fmt.Sprintf("pier: invalid arguments: %q is required", f.name)), 0
}
}
path := t.pattern
for key, value := range pathValues {
if strings.Contains(path, "{"+key+"...}") {
path = strings.ReplaceAll(path, "{"+key+"...}", escapePathSegments(value))
} else {
path = strings.ReplaceAll(path, "{"+key+"}", url.PathEscape(value))
}
}
target := path
if enc := query.Encode(); enc != "" {
target += "?" + enc
}
var body *bytes.Reader
if t.method != http.MethodGet && t.method != http.MethodDelete {
encoded, err := json.Marshal(parts)
if err != nil {
return toolError("pier: arguments could not be encoded: " + err.Error()), 0
}
body = bytes.NewReader(encoded)
}
subject := id.subjectFor(t.guardName)
if subject == nil {
return toolError(fmt.Sprintf("pier: tool %q is not available to this caller", t.name)), 0
}
handler, ok := t.compiledHandler(d.host)
if !ok {
return toolError(fmt.Sprintf("pier: tool %q is bound to %s %s, which is not compiled", t.name, t.method, t.pattern)), 0
}
callCtx, cancel := context.WithTimeout(ctx, d.cfg.CallTimeout)
defer cancel()
disp, err := t.guard.MCPDispatch(callCtx, *subject, t.method)
if err != nil {
switch {
case errors.Is(err, pact.ErrMCPTokenInvalid):
return toolError(tokenInvalid), 0
case errors.Is(err, errSubjectUnavailable):
return toolError(errSubjectUnavailable.Error()), 0
}
logger().Warn("pier: guard dispatch failed", "tool", t.name, "guard", t.guardName, "error", err)
return toolError(dispatchFailed), 0
}
principal, ok := disp.Principal.(*bouncer.Principal)
if !ok || principal == nil {
return toolError(fmt.Sprintf("pier: guard %s did not resolve a principal", t.guardName)), 0
}
// The marker's guard list and the principal's kind must agree with the
// tool's guard: a frontend provider naming the backend guard, or a
// backend principal on a frontend tool, is a provider bug (A-IN-08).
backendTool := t.guardName == BackendGuard
if principal.Backend != backendTool || (!backendTool && slices.Contains(disp.Guards, BackendGuard)) {
logger().Warn("pier: guard dispatch contradicts the tool's guard", "tool", t.name, "guard", t.guardName, "guards", disp.Guards, "backend_principal", principal.Backend)
return toolError(dispatchFailed), 0
}
credential := disp.Credential
if credential == nil && t.guardName == BackendGuard {
credential = id.credential
}
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
var req *http.Request
if body != nil {
req = httptest.NewRequestWithContext(callCtx, t.method, target, body)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(callCtx, t.method, target, nil)
}
req.Header.Set("Accept", "application/json")
if info.remoteAddr != "" {
req.RemoteAddr = info.remoteAddr
}
if info.host != "" {
req.Host = info.host
}
if info.forwardedFor != "" {
req.Header.Set("X-Forwarded-For", info.forwardedFor)
}
if info.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", info.forwardedProto)
}
if info.forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", info.forwardedHost)
}
if info.realIP != "" {
req.Header.Set("X-Real-IP", info.realIP)
}
if info.acceptLanguage != "" {
req.Header.Set("Accept-Language", info.acceptLanguage)
}
for _, seg := range t.segments {
req.SetPathValue(seg, pathValues[seg])
}
req.Pattern = t.method + " " + t.pattern
rec := newCappedRecorder(d.cfg.MaxResultBytes)
handler.ServeHTTP(rec, req)
if rec.overflow {
return toolError(resultTooLarge), rec.rec.Code
}
return mapResult(rec.rec.Code, rec.rec.Body.Bytes()), rec.rec.Code
}
// mapResult turns the route's status and body into the tool result (D-13):
// 2xx is text plus structured content (an object as is, any other JSON
// wrapped as {"data": v}, a non-JSON body text only); anything else is a
// tool error whose text is the route's body (or the status text when the
// body is empty) and whose structured content is the body when it is an
// object.
func mapResult(status int, body []byte) *mcp.CallToolResult {
text := string(body)
structured := structuredOf(body)
if status >= 200 && status < 300 {
res := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
if structured != nil {
res.StructuredContent = structured
} else if v, ok := anyJSON(body); ok {
res.StructuredContent = map[string]any{"data": v}
}
return res
}
if strings.TrimSpace(text) == "" {
text = http.StatusText(status)
if text == "" {
text = "status " + strconv.Itoa(status)
}
}
res := toolError(text)
if structured != nil {
res.StructuredContent = structured
}
return res
}
// toolError is a tool result with IsError set and one text content.
func toolError(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: text}}}
}
// structuredOf decodes body when it is a JSON object, else nil.
func structuredOf(body []byte) map[string]any {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || trimmed[0] != '{' {
return nil
}
var m map[string]any
if err := json.Unmarshal(trimmed, &m); err != nil {
return nil
}
return m
}
// anyJSON decodes body as any JSON value.
func anyJSON(body []byte) (any, bool) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, false
}
var v any
if err := json.Unmarshal(trimmed, &v); err != nil {
return nil, false
}
return v, true
}
// decodeNumbers decodes one JSON value keeping numbers as json.Number, so
// an integer above 2^53 keeps its digits (A-IN-04).
func decodeNumbers(raw json.RawMessage) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
return v, nil
}
// scalarString renders a JSON string, number or boolean as a path value; a
// number is rendered as its literal.
func scalarString(raw json.RawMessage) (string, error) {
v, err := decodeNumbers(raw)
if err != nil {
return "", err
}
return scalarAny(v)
}
// queryStrings renders a JSON scalar or list of scalars as query values.
func queryStrings(raw json.RawMessage) ([]string, error) {
v, err := decodeNumbers(raw)
if err != nil {
return nil, err
}
switch x := v.(type) {
case nil:
return nil, nil
case []any:
var out []string
for _, item := range x {
s, err := scalarAny(item)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, nil
default:
s, err := scalarAny(x)
if err != nil {
return nil, err
}
return []string{s}, nil
}
}
func scalarAny(v any) (string, error) {
switch x := v.(type) {
case string:
return x, nil
case json.Number:
return x.String(), nil
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(x), nil
}
return "", fmt.Errorf("not a scalar")
}
// escapePathSegments escapes a multi-segment value segment by segment.
func escapePathSegments(value string) string {
parts := strings.Split(value, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
// cappedRecorder records a route answer up to a byte limit; beyond it the
// rest is dropped and overflow is set, so a tool result is bounded.
type cappedRecorder struct {
rec *httptest.ResponseRecorder
limit int64
written int64
overflow bool
}
func newCappedRecorder(limit int64) *cappedRecorder {
return &cappedRecorder{rec: httptest.NewRecorder(), limit: limit}
}
func (c *cappedRecorder) Header() http.Header { return c.rec.Header() }
func (c *cappedRecorder) WriteHeader(status int) { c.rec.WriteHeader(status) }
func (c *cappedRecorder) Write(p []byte) (int, error) {
if c.overflow {
return len(p), nil
}
if c.written+int64(len(p)) > c.limit {
c.overflow = true
c.rec.Body.Reset()
return len(p), nil
}
c.written += int64(len(p))
return c.rec.Write(p)
}
// Flush is a no-op: the answer is read once the handler returns.
func (c *cappedRecorder) Flush() {}