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
- Download the shared harness from: harness.py
- Download the verification PoC from: verification_test.py
- Download the matched control from: control-no-sensitive-action.py
- Place the three scripts in the same directory.
- 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
- 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.
- 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.
- 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.
- 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
- 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. |
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/streamand can persist arbitrary configuration changes throughPUT /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:
The same bootstrap also exposes a public SSE endpoint that accepts unauthenticated clients and explicitly returns
Access-Control-Allow-Origin: *: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:
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:doneevent that is visible to any attached SSE listener: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 plainfetch()andEventSource()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, submittedPOST /api/responses, observed the livemessage:doneevent carrying the samemessageId, then sentPUT /api/settingsand confirmed that the attacker-controlled values were persisted to the runtimesettings.jsonon disk.PoC
Prerequisites
packages/main/dist/index.js.localhost:3777.Reproduction Steps
python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-h9g4-589h-68xv-local-control-plane-exp/verification_test.py/api/events/stream, sendsPOST /api/responses, and then sendsPUT /api/settings.verification.logandverification-events.jsonto confirm that the unauthenticated client received both the initialconnectedevent and amessage:doneevent carrying the canarymessageId.verification-observation.jsonand the runtimesettings.jsonunder the generatedruntime-verification/tinyagi-home/directory to confirm that the attacker-supplied settings values were persisted.python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-h9g4-589h-68xv-local-control-plane-exp/control-no-sensitive-action.pyconnectedevent but does not triggermessage:done, and that the settings file remains unchanged.Log of Evidence
Latest verification run:
Runtime daemon evidence from the same run:
Latest matched control:
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:
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
Severity
Weaknesses
Occurrences
tinyagi/packages/server/src/index.ts
Lines 47 to 63 in 1ae8b4e
tinyagi/packages/server/src/index.ts
Lines 78 to 89 in 1ae8b4e
Access-Control-Allow-Origin: *.tinyagi/packages/server/src/routes/settings.ts
Lines 30 to 42 in 1ae8b4e
GET /api/settingsandPUT /api/settingsare exposed without auth, and thePUTpath writes attacker-controlled JSON back toSETTINGS_FILE.tinyagi/packages/server/src/routes/queue.ts
Lines 64 to 90 in 1ae8b4e
POST /api/responsesaccepts unauthenticated caller input and emits a livemessage:doneevent that any attached SSE subscriber can observe.