nextlevelbuilder / nextlevelbuilder/goclaw
[Security] Cross-Agent Authorization Bypass in `/v1/tools/invoke` Allows Unauthorized Cron Binding to Foreign Agents
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 3.6k
- Forks
- 1.1k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 24
Description
Advisory Details
Title: Cross-Agent Authorization Bypass in /v1/tools/invoke Allows Unauthorized Cron Binding to Foreign Agents
Description:
Summary
An authenticated caller with only operator.write privileges can use the documented HTTP tool invocation endpoint, POST /v1/tools/invoke, to create a cron job bound to another user’s private agent. Direct reads of that same foreign agent are correctly blocked with 403, but the cron creation path accepts an attacker-controlled agentId without an access check and persists the unauthorized binding into cron_jobs.agent_id. This creates a stored cross-agent authorization bypass that the scheduler later trusts as the execution target.
Details
The defect is in the HTTP direct-tool entry point. In internal/http/tools_invoke.go, the handler accepts top-level request field agentId, resolves it with agentStore.GetByKey, and injects the resolved UUID into context with store.WithAgentID, but never calls CanAccess before doing so.
if agentIDStr != "" && h.agentStore != nil {
ag, err := h.agentStore.GetByKey(ctx, agentIDStr)
if err == nil {
ctx = store.WithAgentID(ctx, ag.ID)
}
}
That matters because direct agent reads do enforce authorization. In internal/http/agents.go, GET /v1/agents/{id} checks h.agents.CanAccess(...) and returns 403 when the caller is not allowed to access the target agent.
Once the attacker-controlled agent UUID is present in context, the cron tool consumes it in internal/tools/cron.go through resolveAgentIDString(ctx) and passes it into handleAdd(...). The same function also accepts an explicit job.agentId override, which can replace the context agent again before persistence:
// Use agent ID from job object if explicitly provided, otherwise from context
if explicit, _ := jobObj["agentId"].(string); explicit != "" {
agentID = explicit
}
job, err := t.cronStore.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID)
The SQLite cron store then writes that UUID directly into the persisted cron row:
var agentUUID *uuid.UUID
if agentID != "" {
if aid, err := uuid.Parse(agentID); err == nil {
agentUUID = &aid
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO cron_jobs (..., agent_id, user_id, ...) VALUES (...)`,
...,
agentUUID, userIDPtr, ...
)
Finally, the scheduler trusts the stored job.AgentID as the future execution target. In cmd/gateway_cron.go, the cron runner resolves job.AgentID back into an agent key and builds the cron session using that foreign-bound target:
agentID := job.AgentID
...
if ag, err := agentStore.GetByID(cronCtx, id); err == nil {
agentID = ag.AgentKey
}
sessionKey := sessions.BuildCronSessionKey(agentID, job.ID)
In a live local verification against the real HTTP API, I observed the following:
GET /v1/agents/owner-b-agentasownerAreturned403.POST /v1/tools/invokeasownerAwithtool=cronandagentId=owner-b-agentreturned200.- The resulting SQLite row stored
user_id=ownerAwhileagent_idresolved toowner-b-agent, owned byownerB.
That shows a real authorization boundary mismatch rather than a theoretical code smell.
PoC
Prerequisites
- A checkout of the canonical repository
https://github.com/nextlevelbuilder/goclaw - Python 3 available locally
- Go toolchain available to build the
sqliteonlybinary - No external model/provider is required for proving the vulnerable binding itself
- The PoC assumes localhost access only and launches an isolated SQLite-backed gateway instance on
127.0.0.1:18891
Reproduction Steps
- Download the PoC helper from: gist-common.py
- Download the exploit PoC from: gist-verification.py
- Download the control script from: gist-control.py
- Place the three files in the same directory.
- From the repository root, run the exploit PoC:
python3 gist-verification.py - Confirm the baseline protection works first:
the script prints a403result forGET /v1/agents/owner-b-agentasownerA. - Confirm the vulnerable path succeeds:
the script then sendsPOST /v1/tools/invokewithtool=cronandagentId=owner-b-agent, receives200, and prints the returned cron job payload. - Confirm the persisted unauthorized binding:
the script reads the SQLite database and prints a row whereuser_idisownerAbut the boundagent_keyisowner-b-agentandowner_idisownerB. - Run the control case:
python3 gist-control.py - Confirm the control stays within scope:
the control stores a cron job bound toowner-a-agent, showing the issue is specifically caused by the foreign target selection rather than a broken cron subsystem.
Log of Evidence
Observed exploit log:
[STEP] GET /v1/agents/owner-b-agent as ownerA => 403 {'error': {'code': 'UNAUTHORIZED', 'message': 'no access to this agent'}}
[STEP] POST /v1/tools/invoke add foreign cron => 200
[OBSERVE] cron row {"bound_agent_key": "owner-b-agent", "bound_owner": "ownerB", "job_id": "...", "user_id": "ownerA"}
[DEFECT-CONFIRMED-WITH-LIMITATIONS] operator.write caller can persist a cron job bound to a foreign owner agent
Observed control log:
[STEP] GET /v1/agents/owner-b-agent as ownerA => 403
[STEP] POST /v1/tools/invoke add local cron => 200
[OBSERVE] cron row {"bound_agent_key": "owner-a-agent", "bound_owner": "ownerA", "job_id": "...", "user_id": "ownerA"}
[CONTROL-PASS] Removing the foreign target override keeps the cron job bound to the caller-owned agent
One limitation is worth stating clearly: I also attempted to force a cron run through the same public interface, but the minimal harness had no usable model/provider, so the agent turn timed out before a full model-backed completion. That does not affect the core proof here, which is the unauthorized persisted binding and the fact that the scheduler path later trusts that binding.
Impact
This is an authorization bypass affecting scheduled agent execution. A lower-privileged operator can create cron jobs targeting another user’s private agent, even when direct access to that agent is denied. In practice this allows an attacker to plant stored scheduled work that will later run under the foreign agent’s configuration, prompts, tools, and execution context. The exposed asset is not just metadata visibility; it is the integrity of agent ownership boundaries and the scheduler’s trust in persisted work items.
Affected products
- Ecosystem: go
- Package name: github.com/nextlevelbuilder/goclaw
- Affected versions: <= 3.13.2
- Patched versions:
Severity
- Severity: High
- Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L
Weaknesses
- CWE: CWE-862: Missing Authorization
Occurrences
| Permalink | Description |
|---|---|
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/internal/http/tools_invoke.go#L95-L103 | POST /v1/tools/invoke resolves attacker-controlled agentId and injects the resolved agent UUID into context without an authorization check. |
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/internal/http/agents.go#L331-L339 | Direct reads by agent_key are protected with CanAccess, demonstrating the intended authorization boundary that the tool-invocation path skips. |
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/internal/tools/cron.go#L152-L165 | The cron tool reads the current agent UUID from execution context and routes add requests into handleAdd(...) using that untrusted target. |
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/internal/tools/cron.go#L291-L296 | handleAdd(...) accepts an explicit job.agentId override and forwards the chosen target directly into persistence. |
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/internal/store/sqlitestore/cron_crud.go#L55-L80 | The SQLite cron store converts the provided agent UUID and writes it into cron_jobs.agent_id without re-checking ownership or accessibility. |
| https://github.com/nextlevelbuilder/goclaw/blob/d85bf17171fd0faefbbd54df44bef573991aa7f8/cmd/gateway_cron.go#L29-L51 | The cron scheduler later trusts the stored job.AgentID, resolves it back into an agent key, and builds the scheduled execution session from that foreign-bound target. |
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in internal/http/tools_invoke.go and compare its agent resolution with the CanAccess check in internal/http/agents.go. Trace the selected agent through internal/tools/cron.go, internal/store/sqlitestore/cron_crud.go, and cmd/gateway_cron.go. Done means a foreign agent cannot be bound through POST /v1/tools/invoke while the owner-a-agent control case still succeeds; verify with the linked Python PoC and control script.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, authorization, backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100