Advisory Details
Title: TinyAGI allows unauthenticated API callers to exfiltrate arbitrary local files via outbound channel attachments
Description:
Unauthenticated callers can submit attacker-chosen local file paths in files[] to TinyAGI's proactive response API. The service persists those paths and later opens them as Telegram, Discord, or WhatsApp attachments without restricting them to an application-controlled directory.
Summary
TinyAGI exposes POST /api/responses without authentication. In affected releases, an attacker can supply arbitrary readable host paths in files[], and the channel workers later open those paths with InputFile, AttachmentBuilder, or MessageMedia.fromFilePath() and send the resulting file to an attacker-selected recipient. This enables remote local-file disclosure through the outbound messaging pipeline whenever a channel worker is active.
Details
The issue is a trust-boundary failure between the public proactive-response API and the internal attachment-delivery pipeline.
The public route accepts caller-controlled senderId and files[] and forwards both fields directly into the response queue:
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[];
};
...
enqueueResponse({
channel,
sender,
senderId,
message,
originalMessage: '',
messageId,
agent,
files: files && files.length > 0 ? files : undefined,
});
});
That queue entry is stored verbatim in SQLite:
`INSERT INTO responses (message_id,channel,sender,sender_id,message,original_message,agent,files,metadata,status,created_at)
VALUES (?,?,?,?,?,?,?,?,?,'pending',?)`
...
data.files ? JSON.stringify(data.files) : null
The downstream workers then reuse the attacker-controlled senderId as the proactive destination and open each queued file path after only an existence check. Telegram uses new InputFile(file), Discord uses new AttachmentBuilder(file), and WhatsApp uses MessageMedia.fromFilePath(file). There is no authentication on POST /api/responses, no canonicalization, no restriction to .tinyagi/files, and no separation between trusted internally generated attachments and untrusted API-supplied paths.
I verified the source-to-sink chain through the real built daemon and the real public HTTP routes:
POST /api/responses -> packages/server/src/routes/queue.ts -> enqueueResponse() -> SQLite responses.files -> channel worker proactive-send path -> local file open helper
This was not a static-only finding. The included verification script started node packages/main/dist/index.js, sent a real HTTP POST /api/responses with files: ["/etc/hosts"], retrieved the pending queue row through GET /api/responses/pending?channel=telegram, and confirmed the exact path survived unchanged. A second observation used the real whatsapp-web.js helper to materialize /etc/hosts and confirmed the resulting bytes matched the on-disk file.
For versioning, the vulnerable proactive-response path and channel attachment sinks are already present in v0.0.10, and the latest published upstream release on GitHub is v0.0.20 (published on March 26, 2026), which is still affected. The canonical upstream repository resolves to TinyAGI/tinyagi, so the occurrence permalinks below use that repository and the full 40-character commit for v0.0.20: 1ae8b4eba2fcb963a076655a7293fc77eafeb84c.
PoC
Prerequisites
- Node.js installed locally
- Python 3 available to run the PoC scripts
- Repository checked out from the canonical upstream project
- Built runtime artifact present at
packages/main/dist/index.js
- No authentication is required for
POST /api/responses
- No live Telegram, Discord, or WhatsApp credentials are required for the integration-level proof, but a configured channel worker is required to observe final external delivery
Reproduction Steps
- Download the shared harness from: harness.py
- Download the verification PoC from: verification_test.py
- Download the matched control PoC from: control-no-files.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-jjgj-cpp9-cvpv-unauthenticated-response-file-attachment-exp/verification_test.py
- Confirm the script starts
node packages/main/dist/index.js, sends POST /api/responses with files: ["/etc/hosts"], and writes verification.log.
- Inspect
verification.log and confirm the pending response row contains files=["/etc/hosts"], source_sink_consistent=True, and a WhatsApp materialization result with matchesDisk: true.
- Run the matched control:
python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-jjgj-cpp9-cvpv-unauthenticated-response-file-attachment-exp/control-no-files.py
- Confirm
control.log shows the same public route works without files, but no attachment paths are queued.
- For full end-to-end confirmation, configure a real Telegram, Discord, or WhatsApp worker, then repeat the same
files: ["/etc/hosts"] request and observe the outbound attachment delivery to the specified senderId.
Log of Evidence
Verification run:
[Mode] Integration-Test
[Reachability] POST /api/responses -> enqueueResponse() -> SQLite responses.files -> GET /api/responses/pending -> channel worker file open helpers
[E2E Blocker] Real Telegram/Discord/WhatsApp delivery requires live third-party bot credentials or authenticated session state not provided by the repository.
[Input] messageId=proactive_dmcyjm7j
[Pending Response] {"agent": "tinyagi", "channel": "telegram", "files": ["/etc/hosts"], "id": 1, "message": "proof", "messageId": "proactive_dmcyjm7j", "originalMessage": "", "sender": "attacker", "senderId": "123456789"}
[Queue Observation] files=['/etc/hosts']
[Queue Observation] source_sink_consistent=True
[Independent Observation] whatsapp-web.js materialization={"matchesDisk": true, "mimetype": null, "preview": "# This file was automatically generated by WSL. To stop automatic generation of ", "size": 411}
[DEFECT-CONFIRMED-WITH-LIMITATIONS]
Matched control:
[Mode] Integration-Test Control
[Input] Standard POST /api/responses without a files field
[Pending Response] {"agent": "tinyagi", "channel": "telegram", "id": 1, "message": "control", "messageId": "proactive_1qzlpxb9", "originalMessage": "", "sender": "attacker", "senderId": "123456789"}
[Control Observation] no_files=True
[CONTROL-PASSED]
Runtime daemon evidence from the same verification:
[INFO] API server listening on http://localhost:3785
[INFO] [API] Proactive response enqueued for telegram/attacker
Impact
Any attacker who can reach TinyAGI's API can turn the proactive-response pipeline into a host-file exfiltration primitive. If a Telegram, Discord, or WhatsApp worker is configured, the attacker can queue readable local paths such as configuration files, logs, API keys, tokens, or other secrets and have the worker attempt to send them to the chosen senderId.
The primary asset at risk is any file readable by the TinyAGI process. This is a network-reachable confidentiality issue in the default proactive response flow, and it also weakens trust boundaries between public API callers and the application's internal file-exchange mechanism.
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:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Weaknesses
- CWE: CWE-73: External Control of File Name or Path
Occurrences
| Permalink |
Description |
|
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, |
|
Public proactive-response API accepts caller-controlled senderId and files[] and forwards them directly into enqueueResponse(). |
|
export function enqueueResponse(data: ResponseJobData): number { |
|
const r = getDb().prepare( |
|
`INSERT INTO responses (message_id,channel,sender,sender_id,message,original_message,agent,files,metadata,status,created_at) |
|
VALUES (?,?,?,?,?,?,?,?,?,'pending',?)` |
|
).run(data.messageId, data.channel, data.sender, data.senderId ?? null, data.message, |
|
data.originalMessage, data.agent ?? null, data.files ? JSON.stringify(data.files) : null, |
|
data.metadata ? JSON.stringify(data.metadata) : null, Date.now()); |
|
Queue persistence layer stores untrusted attachment paths in responses.files without canonicalization or any trusted-root restriction. |
|
const pending = pendingMessages.get(messageId); |
|
const targetChatId = pending?.chatId ?? (senderId ? Number(senderId) : null); |
|
|
|
if (targetChatId && !Number.isNaN(targetChatId)) { |
|
// Send any attached files first |
|
if (files.length > 0) { |
|
for (const file of files) { |
|
try { |
|
if (!fs.existsSync(file)) continue; |
|
const ext = path.extname(file).toLowerCase(); |
|
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) { |
|
await bot.api.sendPhoto(targetChatId, new InputFile(file)); |
|
} else if (['.mp3', '.ogg', '.wav', '.m4a'].includes(ext)) { |
|
await bot.api.sendAudio(targetChatId, new InputFile(file)); |
|
} else if (['.mp4', '.avi', '.mov', '.webm'].includes(ext)) { |
|
await bot.api.sendVideo(targetChatId, new InputFile(file)); |
|
} else { |
|
await bot.api.sendDocument(targetChatId, new InputFile(file)); |
|
Telegram proactive-delivery path falls back to caller-supplied senderId and opens each queued path with new InputFile(file) after only fs.existsSync(file). |
|
if (!dmChannel && senderId) { |
|
try { |
|
const user = await client.users.fetch(senderId); |
|
dmChannel = await user.createDM(); |
|
} catch (err) { |
|
log('ERROR', `Could not open DM for senderId ${senderId}: ${(err as Error).message}`); |
|
} |
|
} |
|
|
|
if (dmChannel) { |
|
// Send any attached files |
|
if (files.length > 0) { |
|
const attachments: AttachmentBuilder[] = []; |
|
for (const file of files) { |
|
try { |
|
if (!fs.existsSync(file)) continue; |
|
attachments.push(new AttachmentBuilder(file)); |
|
} catch (fileErr) { |
|
log('ERROR', `Failed to prepare file ${file}: ${(fileErr as Error).message}`); |
|
} |
|
} |
|
if (attachments.length > 0) { |
|
await dmChannel.send({ files: attachments }); |
|
log('INFO', `Sent ${attachments.length} file(s) to Discord`); |
|
Discord proactive-delivery path derives the DM target from senderId and opens attacker-queued file paths with new AttachmentBuilder(file) before sending them. |
|
if (!targetChat && senderId) { |
|
try { |
|
const chatId = senderId.includes('@') ? senderId : `${senderId}@c.us`; |
|
targetChat = await client.getChatById(chatId); |
|
} catch (err) { |
|
log('ERROR', `Could not get chat for senderId ${senderId}: ${(err as Error).message}`); |
|
} |
|
} |
|
|
|
if (targetChat) { |
|
// Send any attached files first |
|
if (files.length > 0) { |
|
for (const file of files) { |
|
try { |
|
if (!fs.existsSync(file)) continue; |
|
const media = MessageMedia.fromFilePath(file); |
|
await targetChat.sendMessage(media); |
|
WhatsApp proactive-delivery path derives the target chat from senderId and materializes each queued path with MessageMedia.fromFilePath(file) after only an existence check. |
Advisory Details
Title: TinyAGI allows unauthenticated API callers to exfiltrate arbitrary local files via outbound channel attachments
Description:
Unauthenticated callers can submit attacker-chosen local file paths in
files[]to TinyAGI's proactive response API. The service persists those paths and later opens them as Telegram, Discord, or WhatsApp attachments without restricting them to an application-controlled directory.Summary
TinyAGI exposes
POST /api/responseswithout authentication. In affected releases, an attacker can supply arbitrary readable host paths infiles[], and the channel workers later open those paths withInputFile,AttachmentBuilder, orMessageMedia.fromFilePath()and send the resulting file to an attacker-selected recipient. This enables remote local-file disclosure through the outbound messaging pipeline whenever a channel worker is active.Details
The issue is a trust-boundary failure between the public proactive-response API and the internal attachment-delivery pipeline.
The public route accepts caller-controlled
senderIdandfiles[]and forwards both fields directly into the response queue:That queue entry is stored verbatim in SQLite:
The downstream workers then reuse the attacker-controlled
senderIdas the proactive destination and open each queued file path after only an existence check. Telegram usesnew InputFile(file), Discord usesnew AttachmentBuilder(file), and WhatsApp usesMessageMedia.fromFilePath(file). There is no authentication onPOST /api/responses, no canonicalization, no restriction to.tinyagi/files, and no separation between trusted internally generated attachments and untrusted API-supplied paths.I verified the source-to-sink chain through the real built daemon and the real public HTTP routes:
POST /api/responses->packages/server/src/routes/queue.ts->enqueueResponse()-> SQLiteresponses.files-> channel worker proactive-send path -> local file open helperThis was not a static-only finding. The included verification script started
node packages/main/dist/index.js, sent a real HTTPPOST /api/responseswithfiles: ["/etc/hosts"], retrieved the pending queue row throughGET /api/responses/pending?channel=telegram, and confirmed the exact path survived unchanged. A second observation used the realwhatsapp-web.jshelper to materialize/etc/hostsand confirmed the resulting bytes matched the on-disk file.For versioning, the vulnerable proactive-response path and channel attachment sinks are already present in
v0.0.10, and the latest published upstream release on GitHub isv0.0.20(published on March 26, 2026), which is still affected. The canonical upstream repository resolves toTinyAGI/tinyagi, so the occurrence permalinks below use that repository and the full 40-character commit forv0.0.20:1ae8b4eba2fcb963a076655a7293fc77eafeb84c.PoC
Prerequisites
packages/main/dist/index.jsPOST /api/responsesReproduction Steps
python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-jjgj-cpp9-cvpv-unauthenticated-response-file-attachment-exp/verification_test.pynode packages/main/dist/index.js, sendsPOST /api/responseswithfiles: ["/etc/hosts"], and writesverification.log.verification.logand confirm the pending response row containsfiles=["/etc/hosts"],source_sink_consistent=True, and a WhatsApp materialization result withmatchesDisk: true.python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-jjgj-cpp9-cvpv-unauthenticated-response-file-attachment-exp/control-no-files.pycontrol.logshows the same public route works withoutfiles, but no attachment paths are queued.files: ["/etc/hosts"]request and observe the outbound attachment delivery to the specifiedsenderId.Log of Evidence
Verification run:
Matched control:
Runtime daemon evidence from the same verification:
Impact
Any attacker who can reach TinyAGI's API can turn the proactive-response pipeline into a host-file exfiltration primitive. If a Telegram, Discord, or WhatsApp worker is configured, the attacker can queue readable local paths such as configuration files, logs, API keys, tokens, or other secrets and have the worker attempt to send them to the chosen
senderId.The primary asset at risk is any file readable by the TinyAGI process. This is a network-reachable confidentiality issue in the default proactive response flow, and it also weakens trust boundaries between public API callers and the application's internal file-exchange mechanism.
Affected products
Severity
Weaknesses
Occurrences
tinyagi/packages/server/src/routes/queue.ts
Lines 65 to 85 in 1ae8b4e
senderIdandfiles[]and forwards them directly intoenqueueResponse().tinyagi/packages/core/src/queues.ts
Lines 152 to 158 in 1ae8b4e
responses.fileswithout canonicalization or any trusted-root restriction.tinyagi/packages/channels/src/telegram.ts
Lines 511 to 528 in 1ae8b4e
senderIdand opens each queued path withnew InputFile(file)after onlyfs.existsSync(file).tinyagi/packages/channels/src/discord.ts
Lines 413 to 436 in 1ae8b4e
senderIdand opens attacker-queued file paths withnew AttachmentBuilder(file)before sending them.tinyagi/packages/channels/src/whatsapp.ts
Lines 413 to 429 in 1ae8b4e
senderIdand materializes each queued path withMessageMedia.fromFilePath(file)after only an existence check.