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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user