Skip to content

[Security] TinyAGI exposes an unauthenticated local control plane that leaks live events and allows persistent settings modification #288

Description

@YLChen-007

Advisory Details

Title: TinyAGI exposes an unauthenticated local control plane that leaks live events and allows persistent settings modification

Description:

Summary

TinyAGI exposes a localhost-oriented REST + SSE control plane without any authentication boundary. Any same-host process that can reach the API port can subscribe to live event traffic over GET /api/events/stream and can persist arbitrary configuration changes through PUT /api/settings. In affected releases, this is not a debug-only path or a mock setup; it is the documented control plane used by TinyOffice.

Details

The issue is a missing-authorization flaw in the main API bootstrap and its control-plane routes.

The server starts a Hono app, enables permissive CORS globally, and mounts all route modules before any auth middleware is applied:

const app = new Hono();

// CORS middleware
app.use('/*', cors());

// Mount route modules
app.route('/', messagesRoutes);
app.route('/', agentsRoutes);
app.route('/', teamsRoutes);
app.route('/', settingsRoutes);
app.route('/', createQueueRoutes());

The same bootstrap also exposes a public SSE endpoint that accepts unauthenticated clients and explicitly returns Access-Control-Allow-Origin: *:

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);

The settings mutation route performs no caller verification at all. It accepts arbitrary JSON, merges it with the current configuration, and writes it back to the persisted settings file:

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 });
});

The event stream is not just passively readable. The public proactive-response route can be invoked by an unauthenticated caller and emits a live message:done event that is visible to any attached SSE listener:

app.post('/api/responses', async (c) => {
    const body = await c.req.json();
    const { channel, sender, senderId, message, agent, files } = body as {
        channel?: string; sender?: string; senderId?: string;
        message?: string; agent?: string; files?: string[];
    };

    if (!channel || !sender || !message) {
        return c.json({ error: 'channel, sender, and message are required' }, 400);
    }
    ...
    emitEvent('message:done', { channel, sender, messageId });

This is reachable through the product’s intended deployment model. TinyOffice is documented as a web portal that connects directly to the local TinyAGI API at localhost:3777, and its frontend code uses plain fetch() and EventSource() without auth headers or tokens. The trust assumption is therefore “localhost implies trusted caller,” which is not a real authorization boundary.

I verified this path end to end against the real built daemon. The PoC started node packages/main/dist/index.js, connected an unauthenticated SSE client, submitted POST /api/responses, observed the live message:done event carrying the same messageId, then sent PUT /api/settings and confirmed that the attacker-controlled values were persisted to the runtime settings.json on disk.

PoC

Prerequisites

  • Node.js installed locally.
  • Python 3 available to run the PoC harness.
  • Repository checked out from the canonical upstream project.
  • Built daemon artifact present at packages/main/dist/index.js.
  • No API authentication is required; the control plane is reachable as soon as the daemon is running.
  • Same-host network access to the TinyAGI API port. The documented default is localhost:3777.

Reproduction Steps

  1. Download the shared harness from: harness.py
  2. Download the verification PoC from: verification_test.py
  3. Download the matched control from: control-no-sensitive-action.py
  4. Place the three scripts in the same directory.
  5. From the repository root, run the verification script:
    python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-h9g4-589h-68xv-local-control-plane-exp/verification_test.py
  6. Confirm that the script starts the real TinyAGI daemon, opens an SSE client to /api/events/stream, sends POST /api/responses, and then sends PUT /api/settings.
  7. Inspect the resulting verification.log and verification-events.json to confirm that the unauthenticated client received both the initial connected event and a message:done event carrying the canary messageId.
  8. Inspect verification-observation.json and the runtime settings.json under the generated runtime-verification/tinyagi-home/ directory to confirm that the attacker-supplied settings values were persisted.
  9. Run the matched control:
    python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-h9g4-589h-68xv-local-control-plane-exp/control-no-sensitive-action.py
  10. Confirm the control receives the unauthenticated connected event but does not trigger message:done, and that the settings file remains unchanged.

Log of Evidence

Latest verification run:

[Mode] End-to-End
[Interface] HTTP API + SSE via http://127.0.0.1:39003
[SSE] connected event observed by unauthenticated client
[POST /api/responses] HTTP 200, messageId=proactive_4nhylab1, canary=control-plane-canary-1781827789106
[SSE] message:done event for canary observed
[PUT /api/settings] HTTP 200 and ok=true
[Independent Observation] persisted provider=openai
[Independent Observation] persisted model=gpt-5.3-codex
[Independent Observation] persisted heartbeat_interval=424242
[DEFECT-CONFIRMED]

Runtime daemon evidence from the same run:

[INFO] API server listening on http://localhost:39003
[INFO] [API] Proactive response enqueued for api/attacker
[INFO] [API] Settings updated

Latest matched control:

[Mode] End-to-End Control
[Interface] HTTP API + SSE via http://127.0.0.1:60579
[SSE] connected event observed by unauthenticated client
[POST /api/responses missing message] HTTP 400, body={'error': 'channel, sender, and message are required'}
[SSE] no message:done events observed without a valid proactive response
[Independent Observation] settings file remained unchanged
[CONTROL-PASSED]

Impact

This is an unauthorized control-plane access vulnerability. Any process that can reach the TinyAGI API port can observe live control-plane traffic and can persistently alter daemon configuration without authentication.

The direct impact includes:

  • exposure of live event activity, including response-processing metadata and operational signals available over SSE;
  • integrity loss of persisted configuration, including provider/model selection and monitoring behavior;
  • expansion to broader unauthorized administration because the same missing-auth pattern affects the rest of the mounted control-plane route tree.

In practical deployments, this can let an attacker redirect model configuration, influence operational behavior, and build toward further API-driven control of agents, services, schedules, queue state, or other local control surfaces that trust the same unauthenticated caller model.

Affected products

  • Ecosystem: npm
  • Package name: tinyagi
  • Affected versions: >= 0.0.10, <= 0.0.20
  • Patched versions:

Severity

  • Severity: High
  • Vector string: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L

Weaknesses

  • CWE: CWE-862: Missing Authorization

Occurrences

Permalink Description
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);
API bootstrap enables permissive CORS and mounts every control-plane route module without any authentication middleware.
// SSE endpoint — needs raw Node.js response for streaming
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));
Public SSE endpoint accepts unauthenticated subscribers and explicitly returns Access-Control-Allow-Origin: *.
// GET /api/settings
app.get('/api/settings', (c) => {
return c.json(getSettings());
});
// PUT /api/settings
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 });
GET /api/settings and PUT /api/settings are exposed without auth, and the PUT path writes attacker-controlled JSON back to SETTINGS_FILE.
// POST /api/responses — enqueue a proactive outgoing message
app.post('/api/responses', async (c) => {
const body = await c.req.json();
const { channel, sender, senderId, message, agent, files } = body as {
channel?: string; sender?: string; senderId?: string;
message?: string; agent?: string; files?: string[];
};
if (!channel || !sender || !message) {
return c.json({ error: 'channel, sender, and message are required' }, 400);
}
const messageId = genId('proactive');
enqueueResponse({
channel,
sender,
senderId,
message,
originalMessage: '',
messageId,
agent,
files: files && files.length > 0 ? files : undefined,
});
log('INFO', `[API] Proactive response enqueued for ${channel}/${sender}`);
emitEvent('message:done', { channel, sender, messageId });
return c.json({ ok: true, messageId });
POST /api/responses accepts unauthenticated caller input and emits a live message:done event that any attached SSE subscriber can observe.

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