Skip to content

[Security] TinyAGI allows unauthenticated API callers to exfiltrate arbitrary local files via outbound channel attachments #289

Description

@YLChen-007

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

  1. Download the shared harness from: harness.py
  2. Download the verification PoC from: verification_test.py
  3. Download the matched control PoC from: control-no-files.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-jjgj-cpp9-cvpv-unauthenticated-response-file-attachment-exp/verification_test.py
  6. Confirm the script starts node packages/main/dist/index.js, sends POST /api/responses with files: ["/etc/hosts"], and writes verification.log.
  7. 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.
  8. 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
  9. Confirm control.log shows the same public route works without files, but no attachment paths are queued.
  10. 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.

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