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)
This commit is contained in:
Jakub Zych
2026-10-23 16:05:32 +02:00
parent 240457291f
commit 104db9959f
5 changed files with 134 additions and 23 deletions

View File

@@ -4,16 +4,19 @@ 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"
)
@@ -21,12 +24,24 @@ import (
// 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 and the forwarding, real-ip and language headers, plus
// the session the call belongs to, for the audit line.
// 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
@@ -39,7 +54,10 @@ func callInfoFrom(r *http.Request) 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"),
}
@@ -101,6 +119,13 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
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:
@@ -114,6 +139,13 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
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+"...}") {
@@ -146,12 +178,27 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
defer cancel()
disp, err := t.guard.MCPDispatch(callCtx, *subject, t.method)
if err != nil {
return toolError("pier: " + err.Error()), 0
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
@@ -169,9 +216,18 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
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)
}
@@ -253,27 +309,32 @@ func anyJSON(body []byte) (any, bool) {
return v, true
}
// scalarString renders a JSON string or number as a path value.
func scalarString(raw json.RawMessage) (string, error) {
// 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 := json.Unmarshal(raw, &v); err != nil {
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
}
switch x := v.(type) {
case string:
return x, nil
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(x), nil
}
return "", fmt.Errorf("not a scalar")
return scalarAny(v)
}
// queryStrings renders a JSON scalar or list of scalars as query values.
func queryStrings(raw json.RawMessage) ([]string, error) {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
v, err := decodeNumbers(raw)
if err != nil {
return nil, err
}
switch x := v.(type) {
@@ -302,6 +363,8 @@ 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: