Advisory Details
Title: TinyAGI Unauthenticated Local Control API Allows Persistent Settings Mutation, Agent Prompt Overwrite, and Event Stream Access
Description:
Summary
TinyAGI exposes privileged control-plane HTTP endpoints and its SSE event stream without authentication. In the latest released version, any client that can reach the API can modify persisted configuration, overwrite an agent workspace prompt file, and subscribe to live runtime events. I verified the issue end-to-end against release v0.0.20, including independent confirmation that the server accepted requests on a non-loopback address and was listening on *:3777.
Details
The root cause is straightforward: the API server mounts management routes with global CORS but no authentication middleware, and the affected handlers write attacker-controlled data directly to disk.
In packages/server/src/index.ts, the server enables CORS for every path and mounts the control-plane routes without any access-control layer in front of them:
app.use('/*', cors());
app.route('/', agentsRoutes);
app.route('/', settingsRoutes);
The same file also exposes the SSE endpoint to any caller and explicitly returns Access-Control-Allow-Origin: *:
app.get('/api/events/stream', (c) => {
nodeRes.writeHead(200, {
'Content-Type': 'text/event-stream',
'Access-Control-Allow-Origin': '*',
});
The write paths are equally direct:
packages/server/src/routes/settings.ts accepts JSON from PUT /api/settings, merges it into the current settings object, and writes the result to settings.json.
packages/server/src/routes/agents.ts accepts JSON from PUT /api/agents/:id/system-prompt and writes the caller-controlled content field directly to the target workspace AGENTS.md.
During verification, the server returned 200 for all three unauthenticated requests, the canary value appeared in both sink files, SSE returned event: connected, and a non-loopback request to /api/status also returned 200. The listener state confirmed the service was bound on *:3777, so this is not just a theoretical localhost trust-boundary issue.
PoC
Prerequisites
- TinyAGI checked out at release
v0.0.20 or another unfixed build containing the same code paths
- Node.js with the built
packages/*/dist artifacts available
- Python 3
- No credentials, API token, or existing session required
Reproduction Steps
- Download the verification script from: verification_test.py
- Download the control script from: control-readonly-baseline.py
- If you need the workflow-compatible wrapper filename used in my artifact set, download: verification_test_Advisory-GHSA-h9g4-589h-68xv.py
- From the repository root, ensure the build artifacts exist. If they do not, build them first with
npm run build.
- Run the end-to-end verification script:
LOCAL_API_CONTROL_TEST_PORT=3777 python3 verification_test.py
- Observe that the script starts the real TinyAGI daemon, sends unauthenticated
PUT /api/settings, unauthenticated PUT /api/agents/alpha/system-prompt, and unauthenticated GET /api/events/stream, then independently checks settings.json, AGENTS.md, and the listener state.
- Run the control script on a different port:
LOCAL_API_CONTROL_TEST_PORT=3778 python3 control-readonly-baseline.py
- Compare the results. The verification run should confirm persistent state changes and SSE access, while the control run should leave both sink files unchanged.
Log of Evidence
Verification mode: End-to-End
Status probe: code=200 body={"ok":true,"uptime":3,"server":{"running":true,"port":3777},"channels":{},"heartbeat":{"running":true,"interval":3600,"lastSent":{}}}
Unauthenticated PUT /api/settings: code=200 body={"ok":true,"settings":{...}}
Unauthenticated PUT /api/agents/alpha/system-prompt: code=200 body={"ok":true}
Unauthenticated GET /api/events/stream: code=200 cors=* prefix='event: connected\n'
Non-loopback GET /api/status via 172.25.129.5: code=200 body={"ok":true,"uptime":3,"server":{"running":true,"port":3777},...}
Canary present in settings.json: True
Canary present in AGENTS.md: True
Listener observation:
LISTEN 0 511 *:3777 *:* users:(("node",pid=2762943,fd=27))
[DEFECT-CONFIRMED] Unauthenticated control requests mutated persistent state and reached the SSE stream.
Control mode: End-to-End
PUT /api/agents/missing/system-prompt: code=404 body={"error":"agent 'missing' not found"}
Canary present in settings.json: False
Canary present in AGENTS.md: False
[CONTROL-PASS] Same environment remained reachable, but no canary was persisted without the privileged write to an existing target.
Impact
This is an authentication bypass on TinyAGI's management plane. Any reachable client can:
- mutate persisted
settings.json,
- overwrite an agent's
AGENTS.md prompt state,
- subscribe to the live SSE event stream,
- and potentially influence future agent behavior or observe runtime activity without authorization.
The issue affects the control plane rather than a low-risk read-only endpoint. In practice, that means configuration integrity is lost immediately, prompt trust is lost immediately, and any events pushed over SSE become available to an unauthorized observer. If the API is exposed beyond the local host through Docker port publishing, host networking, or an operator's browser context, the blast radius expands accordingly.
Affected products
- Ecosystem: npm
- Package name: tinyagi
- Affected versions: <= 0.0.20
- Patched versions:
Severity
- Severity: High
- Vector string: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L
Weaknesses
- CWE: CWE-306: Missing Authentication for Critical Function
Occurrences
| Permalink |
Description |
|
// CORS middleware |
|
app.use('/*', cors()); |
|
|
|
// Mount route modules |
|
app.route('/', messagesRoutes); |
|
app.route('/', agentsRoutes); |
|
app.route('/', teamsRoutes); |
|
app.route('/', settingsRoutes); |
|
app.route('/', createQueueRoutes()); |
|
app.route('/', tasksRoutes); |
|
app.route('/', projectsRoutes); |
|
app.route('/', logsRoutes); |
|
app.route('/', chatsRoutes); |
|
app.route('/', chatroomRoutes); |
|
app.route('/', agentMessagesRoutes); |
|
app.route('/', createServicesRoutes(services)); |
|
app.route('/', pairingRoutes); |
|
app.route('/', schedulesRoutes); |
|
The API server enables global CORS and mounts the management route modules directly, with no authentication middleware or loopback-only enforcement in front of the control plane. |
|
app.get('/api/events/stream', (c) => { |
|
const nodeRes = (c.env as { outgoing: http.ServerResponse }).outgoing; |
|
nodeRes.writeHead(200, { |
|
'Content-Type': 'text/event-stream', |
|
'Cache-Control': 'no-cache', |
|
'Connection': 'keep-alive', |
|
'Access-Control-Allow-Origin': '*', |
|
}); |
|
nodeRes.write(`event: connected\ndata: ${JSON.stringify({ timestamp: Date.now() })}\n\n`); |
|
addSSEClient(nodeRes); |
|
nodeRes.on('close', () => removeSSEClient(nodeRes)); |
|
The SSE endpoint accepts any client and explicitly returns Access-Control-Allow-Origin: *, allowing unauthenticated event-stream access. |
|
app.put('/api/settings', async (c) => { |
|
const body = await c.req.json(); |
|
const current = getSettings(); |
|
const merged = { ...current, ...body } as Settings; |
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2) + '\n'); |
|
log('INFO', '[API] Settings updated'); |
|
return c.json({ ok: true, settings: merged }); |
|
PUT /api/settings accepts attacker-controlled JSON and persists it directly into settings.json without any authentication gate. |
|
app.put('/api/agents/:id/system-prompt', async (c) => { |
|
const agentId = c.req.param('id'); |
|
const settings = getSettings(); |
|
const agent = settings.agents?.[agentId]; |
|
if (!agent) return c.json({ error: `agent '${agentId}' not found` }, 404); |
|
|
|
const body = await c.req.json() as { content: string }; |
|
const agentsMd = path.join(agent.working_directory, 'AGENTS.md'); |
|
fs.writeFileSync(agentsMd, body.content || '', 'utf8'); |
|
return c.json({ ok: true }); |
|
PUT /api/agents/:id/system-prompt writes caller-controlled content directly into the target agent workspace AGENTS.md, allowing unauthorized prompt overwrite. |
Advisory Details
Title: TinyAGI Unauthenticated Local Control API Allows Persistent Settings Mutation, Agent Prompt Overwrite, and Event Stream Access
Description:
Summary
TinyAGI exposes privileged control-plane HTTP endpoints and its SSE event stream without authentication. In the latest released version, any client that can reach the API can modify persisted configuration, overwrite an agent workspace prompt file, and subscribe to live runtime events. I verified the issue end-to-end against release
v0.0.20, including independent confirmation that the server accepted requests on a non-loopback address and was listening on*:3777.Details
The root cause is straightforward: the API server mounts management routes with global CORS but no authentication middleware, and the affected handlers write attacker-controlled data directly to disk.
In
packages/server/src/index.ts, the server enables CORS for every path and mounts the control-plane routes without any access-control layer in front of them:The same file also exposes the SSE endpoint to any caller and explicitly returns
Access-Control-Allow-Origin: *:The write paths are equally direct:
packages/server/src/routes/settings.tsaccepts JSON fromPUT /api/settings, merges it into the current settings object, and writes the result tosettings.json.packages/server/src/routes/agents.tsaccepts JSON fromPUT /api/agents/:id/system-promptand writes the caller-controlledcontentfield directly to the target workspaceAGENTS.md.During verification, the server returned
200for all three unauthenticated requests, the canary value appeared in both sink files, SSE returnedevent: connected, and a non-loopback request to/api/statusalso returned200. The listener state confirmed the service was bound on*:3777, so this is not just a theoretical localhost trust-boundary issue.PoC
Prerequisites
v0.0.20or another unfixed build containing the same code pathspackages/*/distartifacts availableReproduction Steps
npm run build.LOCAL_API_CONTROL_TEST_PORT=3777 python3 verification_test.pyPUT /api/settings, unauthenticatedPUT /api/agents/alpha/system-prompt, and unauthenticatedGET /api/events/stream, then independently checkssettings.json,AGENTS.md, and the listener state.LOCAL_API_CONTROL_TEST_PORT=3778 python3 control-readonly-baseline.pyLog of Evidence
Impact
This is an authentication bypass on TinyAGI's management plane. Any reachable client can:
settings.json,AGENTS.mdprompt state,The issue affects the control plane rather than a low-risk read-only endpoint. In practice, that means configuration integrity is lost immediately, prompt trust is lost immediately, and any events pushed over SSE become available to an unauthorized observer. If the API is exposed beyond the local host through Docker port publishing, host networking, or an operator's browser context, the blast radius expands accordingly.
Affected products
Severity
Weaknesses
Occurrences
tinyagi/packages/server/src/index.ts
Lines 46 to 63 in 1ae8b4e
tinyagi/packages/server/src/index.ts
Lines 79 to 89 in 1ae8b4e
Access-Control-Allow-Origin: *, allowing unauthenticated event-stream access.tinyagi/packages/server/src/routes/settings.ts
Lines 36 to 42 in 1ae8b4e
PUT /api/settingsaccepts attacker-controlled JSON and persists it directly intosettings.jsonwithout any authentication gate.tinyagi/packages/server/src/routes/agents.ts
Lines 226 to 235 in 1ae8b4e
PUT /api/agents/:id/system-promptwrites caller-controlled content directly into the target agent workspaceAGENTS.md, allowing unauthorized prompt overwrite.