Skip to content

[Security] TinyAGI Unauthenticated Local Control API Allows Persistent Settings Mutation, Agent Prompt Overwrite, and Event Stream Access #286

Description

@YLChen-007

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

  1. Download the verification script from: verification_test.py
  2. Download the control script from: control-readonly-baseline.py
  3. If you need the workflow-compatible wrapper filename used in my artifact set, download: verification_test_Advisory-GHSA-h9g4-589h-68xv.py
  4. From the repository root, ensure the build artifacts exist. If they do not, build them first with npm run build.
  5. Run the end-to-end verification script:
    LOCAL_API_CONTROL_TEST_PORT=3777 python3 verification_test.py
  6. 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.
  7. Run the control script on a different port:
    LOCAL_API_CONTROL_TEST_PORT=3778 python3 control-readonly-baseline.py
  8. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions