This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 757ab6a591df2ac395191480b9559b55f1a3a0fb Author: Yang Luo <[email protected]> AuthorDate: Sat Aug 15 14:05:52 2026 +0800 fix(agentpatch): authenticate agent record ingestion /api/add-agent-record treated a loopback socket as authorization. That is not a boundary in either direction. Behind nginx or a Docker port mapping every external request arrives from 127.0.0.1, and because CORS allows any origin with credentials, any web page the operator visits can POST to 127.0.0.1:17000 directly. Either one lets an attacker forge activity, and an audit trail that can be poisoned is worth nothing. Patch now mints a per-installation token, persists it beside the other patch state, and passes it to the reporter it configures. Hooks, the MCP recorder and the OpenClaw handler present it in a header, the endpoint verifies it in constant time, and Unpatch revokes it so a hook left behind cannot keep reporting. The token also pins which agent a reporter may speak for. Requests carrying a forwarding header are refused outright, so loopback means the peer really is local. recordsURL now validates httpport instead of formatting whatever it finds, because an invalid value was being baked into agent configuration files. --- agenthook/hook.go | 4 + agentmonitor/record.go | 5 ++ agentpatch/claude_code.go | 23 ++++-- agentpatch/claude_desktop.go | 17 +++- agentpatch/endpoint.go | 19 ++++- agentpatch/openclaw.go | 38 +++++++-- agentpatch/openclaw_hook.js | 6 +- agentpatch/token.go | 187 +++++++++++++++++++++++++++++++++++++++++++ controllers/agent.go | 49 +++++++++--- mcpserver/mcpserver.go | 47 ++++++----- 10 files changed, 348 insertions(+), 47 deletions(-) diff --git a/agenthook/hook.go b/agenthook/hook.go index b6fff12..3e81664 100644 --- a/agenthook/hook.go +++ b/agenthook/hook.go @@ -59,6 +59,7 @@ func Run(args []string, input io.Reader) error { recordsURL := flags.String("records-url", "", "Gateway agent record endpoint") agentPath := flags.String("agent-path", "", "agent installation path") owner := flags.String("user", "", "agent installation owner") + ingestToken := flags.String("ingest-token", "", "credential presented to the Gateway record endpoint") if err := flags.Parse(args); err != nil { return err } @@ -88,6 +89,9 @@ func Run(args []string, input io.Reader) error { return err } request.Header.Set("Content-Type", "application/json") + if *ingestToken != "" { + request.Header.Set(agentmonitor.IngestTokenHeader, *ingestToken) + } response, err := (&http.Client{Timeout: reportTimeout}).Do(request) if err != nil { return err diff --git a/agentmonitor/record.go b/agentmonitor/record.go index 7e14554..68390f0 100644 --- a/agentmonitor/record.go +++ b/agentmonitor/record.go @@ -21,6 +21,11 @@ import ( "github.com/google/uuid" ) +// IngestTokenHeader carries the per-installation secret that a hook or MCP +// recorder presents when reporting a record. It lives here so that the reporter +// packages and the patcher packages can share it without importing each other. +const IngestTokenHeader = "X-Casbin-Gateway-Agent-Token" + // Record is one observed agent behaviour. It is deliberately independent from // Gateway's HTTP traffic record and is retained only in the live monitor store. type Record struct { diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go index 5a31e0f..328e2a7 100644 --- a/agentpatch/claude_code.go +++ b/agentpatch/claude_code.go @@ -72,13 +72,17 @@ func (claudeCodePatcher) Unpatch(target Target) error { return err } config, mode, exists, err := readJSONConfig(path) - if err != nil || !exists { + if err != nil { return err } - if !removeClaudeCodeHooks(config) { - return nil + // The credential is revoked on every unpatch path, including the ones that + // change no file, so a stale hook can never keep reporting. + if exists && removeClaudeCodeHooks(config) { + if err := writeJSONConfig(path, config, mode); err != nil { + return err + } } - return writeJSONConfig(path, config, mode) + return RevokeIngestToken(target) } func (claudeCodePatcher) Status(target Target) (Status, error) { @@ -118,13 +122,22 @@ func claudeCodeHookCommand(target Target) (string, []string, error) { if err != nil { return "", nil, fmt.Errorf("resolve Gateway executable: %w", err) } + url, err := recordsURL() + if err != nil { + return "", nil, err + } + token, err := IssueIngestToken(target) + if err != nil { + return "", nil, err + } return executable, []string{ agenthook.Subcommand, agenthook.OwnershipFlag, "--agent", "claude-code", - "--records-url", recordsURL(), + "--records-url", url, "--agent-path", target.Path, "--user", target.Owner, + "--ingest-token", token, }, nil } diff --git a/agentpatch/claude_desktop.go b/agentpatch/claude_desktop.go index c53a284..40f396d 100644 --- a/agentpatch/claude_desktop.go +++ b/agentpatch/claude_desktop.go @@ -96,9 +96,11 @@ func (p claudeDesktopPatcher) Unpatch(target Target) error { } if runtime.GOOS == "windows" { - return agentmonitor.DisableCoworkMonitor(target.Path) + if err := agentmonitor.DisableCoworkMonitor(target.Path); err != nil { + return err + } } - return nil + return RevokeIngestToken(target) } // updateClaudeDesktopServers applies mutate to the mcpServers object and writes @@ -177,6 +179,14 @@ func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any, error) if err != nil { return nil, fmt.Errorf("resolve Gateway executable: %w", err) } + url, err := recordsURL() + if err != nil { + return nil, err + } + token, err := IssueIngestToken(target) + if err != nil { + return nil, err + } return map[string]any{ "command": executable, "args": []string{ @@ -184,7 +194,8 @@ func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any, error) "--agent", p.AgentId(), "--agent-path", target.Path, "--user", target.Owner, - "--records-url", recordsURL(), + "--records-url", url, + "--ingest-token", token, }, }, nil } diff --git a/agentpatch/endpoint.go b/agentpatch/endpoint.go index 9a58550..cd5a6e1 100644 --- a/agentpatch/endpoint.go +++ b/agentpatch/endpoint.go @@ -16,14 +16,25 @@ package agentpatch import ( "fmt" + "strconv" + "strings" "github.com/apache/casbin-gateway/conf" ) -func recordsURL() string { - port := conf.GetConfigString("httpport") +const defaultHttpPort = "17000" + +// recordsURL is the loopback endpoint a patched installation reports to. A +// malformed httpport would bake an unreachable URL into an agent's config, so +// it is rejected while the operator can still see the error. +func recordsURL() (string, error) { + port := strings.TrimSpace(conf.GetConfigString("httpport")) if port == "" { - port = "17000" + port = defaultHttpPort + } + number, err := strconv.Atoi(port) + if err != nil || number <= 0 || number > 65535 { + return "", fmt.Errorf("cannot build the agent record endpoint: httpport %q is not a valid port", port) } - return fmt.Sprintf("http://127.0.0.1:%s/api/add-agent-record", port) + return fmt.Sprintf("http://127.0.0.1:%d/api/add-agent-record", number), nil } diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go index 5b25c77..33ce6ce 100644 --- a/agentpatch/openclaw.go +++ b/agentpatch/openclaw.go @@ -21,6 +21,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/apache/casbin-gateway/agentmonitor" ) const ( @@ -50,6 +52,14 @@ func (p openclawPatcher) Patch(target Target) error { if err != nil { return err } + token, err := IssueIngestToken(target) + if err != nil { + return err + } + handler, err := renderOpenclawHandler(target, token) + if err != nil { + return err + } return Apply(target, func(changes *ChangeSet) error { if err := changes.MkdirAll(layout.hookDir); err != nil { return err @@ -57,7 +67,7 @@ func (p openclawPatcher) Patch(target Target) error { if err := changes.WriteFile(filepath.Join(layout.hookDir, "HOOK.md"), []byte(openclawHookManifest), 0o644); err != nil { return err } - if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(renderOpenclawHandler(target)), 0o644); err != nil { + if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(handler), 0o644); err != nil { return err } return enableOpenclawHook(changes, layout.configPath) @@ -65,7 +75,10 @@ func (p openclawPatcher) Patch(target Target) error { } func (openclawPatcher) Unpatch(target Target) error { - return Revert(target) + if err := Revert(target); err != nil { + return err + } + return RevokeIngestToken(target) } func (p openclawPatcher) Status(target Target) (Status, error) { @@ -116,10 +129,23 @@ func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) { }, nil } -func renderOpenclawHandler(target Target) string { - handler := strings.ReplaceAll(openclawHookHandler, "__CASBIN_GATEWAY_RECORDS_URL__", jsonString(recordsURL())) - handler = strings.ReplaceAll(handler, "__CASBIN_GATEWAY_AGENT_PATH__", jsonString(target.Path)) - return strings.ReplaceAll(handler, "__CASBIN_GATEWAY_OWNER__", jsonString(target.Owner)) +func renderOpenclawHandler(target Target, ingestToken string) (string, error) { + url, err := recordsURL() + if err != nil { + return "", err + } + replacements := map[string]string{ + "__CASBIN_GATEWAY_RECORDS_URL__": jsonString(url), + "__CASBIN_GATEWAY_AGENT_PATH__": jsonString(target.Path), + "__CASBIN_GATEWAY_OWNER__": jsonString(target.Owner), + "__CASBIN_GATEWAY_INGEST_TOKEN__": jsonString(ingestToken), + "__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": jsonString(agentmonitor.IngestTokenHeader), + } + handler := openclawHookHandler + for placeholder, value := range replacements { + handler = strings.ReplaceAll(handler, placeholder, value) + } + return handler, nil } func enableOpenclawHook(changes *ChangeSet, configPath string) error { diff --git a/agentpatch/openclaw_hook.js b/agentpatch/openclaw_hook.js index 13c3d02..4150450 100644 --- a/agentpatch/openclaw_hook.js +++ b/agentpatch/openclaw_hook.js @@ -15,6 +15,8 @@ const RECORDS_URL = __CASBIN_GATEWAY_RECORDS_URL__; const AGENT_PATH = __CASBIN_GATEWAY_AGENT_PATH__; const OWNER = __CASBIN_GATEWAY_OWNER__; +const INGEST_TOKEN = __CASBIN_GATEWAY_INGEST_TOKEN__; +const INGEST_TOKEN_HEADER = __CASBIN_GATEWAY_INGEST_TOKEN_HEADER__; const POST_TIMEOUT_MS = 3000; const MAX_OBJECT_CHARS = 64 * 1024; @@ -46,9 +48,11 @@ function objectFor(context) { function post(record) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS); + const headers = { "Content-Type": "application/json" }; + if (INGEST_TOKEN) headers[INGEST_TOKEN_HEADER] = INGEST_TOKEN; fetch(RECORDS_URL, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(record), signal: controller.signal, }).catch(() => {}).finally(() => clearTimeout(timer)); diff --git a/agentpatch/token.go b/agentpatch/token.go new file mode 100644 index 0000000..423ebdc --- /dev/null +++ b/agentpatch/token.go @@ -0,0 +1,187 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentpatch + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const ingestTokenFile = "ingest-tokens.json" + +// ingestToken is the credential one patched installation presents when it +// reports a record. +type ingestToken struct { + Token string `json:"token"` + AgentId string `json:"agentId"` + CreatedTime string `json:"createdTime"` +} + +type ingestTokenStore struct { + mutex sync.Mutex + loaded bool + tokens map[string]ingestToken +} + +// tokens is guarded by its own mutex: patchers call into it while they already +// hold stateMutex, so it must never reach back for that lock. +var tokens = &ingestTokenStore{tokens: map[string]ingestToken{}} + +// IssueIngestToken returns the reporting credential for target, minting and +// persisting a new one when the target has none. Re-patching an installation +// deliberately keeps the existing token so that a hook left behind by a crashed +// patch run does not start failing. +func IssueIngestToken(target Target) (string, error) { + return tokens.issue(target) +} + +// RevokeIngestToken invalidates the credential of one installation. Records +// reported with it afterwards are rejected. +func RevokeIngestToken(target Target) error { + return tokens.revoke(target) +} + +// ValidateIngestToken reports whether presented matches a live installation +// credential, and returns the agent it was issued for. +func ValidateIngestToken(presented string) (string, bool) { + return tokens.validate(presented) +} + +func (store *ingestTokenStore) issue(target Target) (string, error) { + store.mutex.Lock() + defer store.mutex.Unlock() + if err := store.loadLocked(); err != nil { + return "", err + } + + key := targetKey(target) + if existing, found := store.tokens[key]; found && existing.Token != "" { + return existing.Token, nil + } + secret, err := newSecret() + if err != nil { + return "", err + } + store.tokens[key] = ingestToken{ + Token: secret, + AgentId: target.AgentId, + CreatedTime: time.Now().Format(time.RFC3339), + } + if err := store.saveLocked(); err != nil { + delete(store.tokens, key) + return "", err + } + return secret, nil +} + +func (store *ingestTokenStore) revoke(target Target) error { + store.mutex.Lock() + defer store.mutex.Unlock() + if err := store.loadLocked(); err != nil { + return err + } + + key := targetKey(target) + previous, found := store.tokens[key] + if !found { + return nil + } + delete(store.tokens, key) + if err := store.saveLocked(); err != nil { + store.tokens[key] = previous + return err + } + return nil +} + +func (store *ingestTokenStore) validate(presented string) (string, bool) { + if presented == "" { + return "", false + } + + store.mutex.Lock() + defer store.mutex.Unlock() + if err := store.loadLocked(); err != nil { + return "", false + } + for _, issued := range store.tokens { + if subtle.ConstantTimeCompare([]byte(issued.Token), []byte(presented)) == 1 { + return issued.AgentId, true + } + } + return "", false +} + +func (store *ingestTokenStore) loadLocked() error { + if store.loaded { + return nil + } + data, err := os.ReadFile(ingestTokenPath()) + if os.IsNotExist(err) { + store.loaded = true + return nil + } + if err != nil { + return err + } + var saved struct { + Tokens map[string]ingestToken `json:"tokens"` + } + if err := json.Unmarshal(data, &saved); err != nil { + return fmt.Errorf("parse agent ingest tokens: %w", err) + } + if saved.Tokens != nil { + store.tokens = saved.Tokens + } + store.loaded = true + return nil +} + +func (store *ingestTokenStore) saveLocked() error { + path := ingestTokenPath() + if len(store.tokens) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(map[string]any{"tokens": store.tokens}, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func ingestTokenPath() string { + return filepath.Join(stateDir(), ingestTokenFile) +} + +func newSecret() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("generate agent ingest token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} diff --git a/controllers/agent.go b/controllers/agent.go index cd036ff..8225189 100644 --- a/controllers/agent.go +++ b/controllers/agent.go @@ -120,18 +120,20 @@ func (c *ApiController) GetAgentSessions() { c.ResponseOk(agentmonitor.ListSessions(agentmonitor.RecordQuery{})) } -// AddAgentRecord accepts reports from a hook or MCP process launched locally -// by Gateway. Those processes have no browser session, so loopback is the -// authorization boundary. +// AddAgentRecord accepts reports from a hook or MCP process launched locally by +// Gateway. Those processes have no browser session, so they authenticate with +// the per-installation credential issued at Patch time. Loopback alone is not a +// trust boundary: behind a reverse proxy every caller looks local, and any web +// page the operator visits can reach 127.0.0.1. func (c *ApiController) AddAgentRecord() { - remoteAddr := c.Ctx.Request.RemoteAddr - host, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - host = remoteAddr + ip, ok := c.directLoopbackClient() + if !ok { + c.ResponseError("agent record ingestion is limited to direct loopback requests") + return } - ip := net.ParseIP(host) - if ip == nil || !ip.IsLoopback() { - c.ResponseError("agent record ingestion is limited to loopback") + agentId, ok := agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader)) + if !ok { + c.ResponseError("agent record ingestion requires a valid installation token") return } @@ -144,11 +146,38 @@ func (c *ApiController) AddAgentRecord() { c.ResponseError("agent is required") return } + // The token decides which agent a reporter may speak for, so a compromised + // hook cannot attribute its activity to a different installation. + if agentId != "" && !strings.EqualFold(record.Agent, agentId) { + c.ResponseError("agent does not match the installation this token was issued for") + return + } record.ClientIp = ip.String() agentmonitor.AddRecord(&record) c.ResponseOk() } +// directLoopbackClient reports the peer address, rejecting anything that was +// relayed by a proxy. A forwarding header means the real client is remote even +// though the socket is local. +func (c *ApiController) directLoopbackClient() (net.IP, bool) { + for _, header := range []string{"X-Forwarded-For", "X-Real-Ip", "Forwarded"} { + if c.Ctx.Input.Header(header) != "" { + return nil, false + } + } + remoteAddr := c.Ctx.Request.RemoteAddr + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return nil, false + } + return ip, true +} + // readAgentPatchTarget resolves the request body against the installations that // were actually discovered. Patching writes into the owner's home directory, so // an unverified body would let a caller name any account on the host. diff --git a/mcpserver/mcpserver.go b/mcpserver/mcpserver.go index 1d09e27..e7a81f1 100644 --- a/mcpserver/mcpserver.go +++ b/mcpserver/mcpserver.go @@ -63,28 +63,30 @@ var tools = []map[string]any{{ // Server serves one MCP stdio session. type Server struct { - in io.Reader - out io.Writer - agentID string - agentPath string - user string - recordsURL string - client *http.Client + in io.Reader + out io.Writer + agentID string + agentPath string + user string + recordsURL string + ingestToken string + client *http.Client queue chan *agentmonitor.Record reporting sync.WaitGroup } -func newServer(in io.Reader, out io.Writer, agentID, agentPath, user, recordsURL string) *Server { +func newServer(in io.Reader, out io.Writer, agentID, agentPath, user, recordsURL, ingestToken string) *Server { return &Server{ - in: in, - out: out, - agentID: agentID, - agentPath: agentPath, - user: user, - recordsURL: recordsURL, - client: &http.Client{Timeout: reportTimeout}, - queue: make(chan *agentmonitor.Record, reportQueueDepth), + in: in, + out: out, + agentID: agentID, + agentPath: agentPath, + user: user, + recordsURL: recordsURL, + ingestToken: ingestToken, + client: &http.Client{Timeout: reportTimeout}, + queue: make(chan *agentmonitor.Record, reportQueueDepth), } } @@ -110,10 +112,11 @@ func Run(args []string) error { agentID := flags.String("agent", "claude-desktop", "agent that launched this server") agentPath := flags.String("agent-path", "", "agent installation path") user := flags.String("user", "", "agent installation owner") + ingestToken := flags.String("ingest-token", "", "credential presented to the Gateway record endpoint") if err := flags.Parse(args); err != nil { return err } - return newServer(os.Stdin, os.Stdout, *agentID, *agentPath, *user, *recordsURL).Serve() + return newServer(os.Stdin, os.Stdout, *agentID, *agentPath, *user, *recordsURL, *ingestToken).Serve() } // Serve processes line-delimited JSON-RPC messages until the client closes @@ -240,7 +243,15 @@ func (s *Server) startReporter() { if err != nil { continue } - response, err := s.client.Post(s.recordsURL, "application/json", bytes.NewReader(body)) + request, err := http.NewRequest(http.MethodPost, s.recordsURL, bytes.NewReader(body)) + if err != nil { + continue + } + request.Header.Set("Content-Type", "application/json") + if s.ingestToken != "" { + request.Header.Set(agentmonitor.IngestTokenHeader, s.ingestToken) + } + response, err := s.client.Do(request) if err == nil { _ = response.Body.Close() } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
