Skip to content

[Security] Unauthenticated Pairing Management API Allows Arbitrary Approval of Pending Channel Senders #287

Description

@YLChen-007

Advisory Details

Title: Unauthenticated Pairing Management API Allows Arbitrary Approval of Pending Channel Senders

Description:
TinyAGI exposes pairing-management routes that return pending pairing codes and approve pending senders without authentication or operator authorization. Any unauthenticated HTTP client that can reach the API can read a valid pending code from GET /api/pairing and submit it to POST /api/pairing/approve, which moves the sender into the approved allowlist.

Summary

TinyAGI uses sender pairing as the gate for Discord, Telegram, and WhatsApp access, but the HTTP management routes for that feature are public. In the highest affected public release, v0.0.20, any network-reachable unauthenticated client can enumerate pending pairing entries and approve one of them, bypassing the intended operator approval step and granting the sender normal downstream channel access.

Details

The issue is a missing authentication and authorization boundary around the pairing-management API. The route handler in packages/server/src/routes/pairing.ts trusts API reachability as if it were operator trust:

app.get('/api/pairing', (c) => {
    const state = loadPairingState(PAIRING_FILE);
    return c.json(state);
});

That response includes the live pending array from ${TINYAGI_HOME}/pairing.json, including the raw approval codes that are meant to gate first-time sender access.

The approval route is equally exposed:

app.post('/api/pairing/approve', async (c) => {
    const body = await c.req.json() as { code?: string };
    const result = approvePairingCode(PAIRING_FILE, body.code);

There is no authentication middleware, no operator-session check, and no authorization decision before approvePairingCode(...) is invoked.

The underlying sink in packages/core/src/pairing.ts only checks whether the submitted code matches a pending record. Once it does, the function removes the entry from pending, writes a new record into approved, and persists the change back to pairing.json. The helper has no notion of caller identity or operator role, so the HTTP layer is the only place where access control could have been enforced, and it currently is not.

I reproduced this against the real Node service using the public HTTP routes, not by calling internal helpers directly. The verification script seeded a realistic pending pairing entry, requested GET /api/pairing, extracted the returned code, and immediately approved it through POST /api/pairing/approve. A follow-up read of pairing.json showed the same sender moved from pending to approved, and the service log recorded [API] Pairing approved: discord/external-tester.

GitHub now serves the canonical upstream under TinyAGI/tinyagi (the old TinyAGI/tinyclaw path redirects there), so the occurrences below use the canonical repository path with the exact v0.0.20 commit.

PoC

Prerequisites

  • Git checkout of the TinyAGI repository with the vulnerable release content present
  • Built Node artifacts available under packages/*/dist (for example, after npm run build)
  • Python 3 available locally
  • No API token, session, or operator credentials are required for the tested routes
  • A pending pairing entry must exist for the vulnerable approval path to have something to approve

Reproduction Steps

  1. Download the shared helper from: common.py
  2. Download the verification script from: verification_test.py
  3. Download the control script from: control-no-pending-baseline.py
  4. Place the three files in the same directory, or use the existing copies under llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/.
  5. From the repository root, run the verification PoC:
    python3 llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/verification_test.py
  6. Observe that the script launches the real TinyAGI service, seeds one pending Discord sender, retrieves the leaked pending code through GET /api/pairing, and successfully approves it through POST /api/pairing/approve.
  7. Run the baseline control:
    python3 llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/control-no-pending-baseline.py
  8. Observe that the same public API returns no pending entries and rejects an arbitrary code with 404 when no pending record exists.

Log of Evidence

[Mode] Integration-Test
[E2E Blocker] Full channel-driven E2E requires live Discord/Telegram/WhatsApp credentials and an actual unknown sender event to create pending state. TinyAGI exposes no local HTTP endpoint to synthesize that state through the same public ingress.
[Interface] Real HTTP API on localhost: GET /api/pairing -> POST /api/pairing/approve
[Expectation] unauthenticated caller can read a real pending code and approve it without any operator authorization check
[Seeded Pending State] channel=discord senderId=discord-user-28473 code=24398831
GET /api/pairing -> HTTP 200, pending_count=1, leaked_code=24398831
POST /api/pairing/approve -> HTTP 200, ok=True
follow-up GET /api/pairing -> HTTP 200, approved_count=1, approved_sender=discord-user-28473, approved_code=24398831
[Independent Observation] runtime-vuln/pairing.json={
  "pending": [],
  "approved": [
    {
      "channel": "discord",
      "senderId": "discord-user-28473",
      "sender": "external-tester",
      "approvedAt": 1781824401877,
      "approvedCode": "24398831"
    }
  ]
}
[DEFECT-CONFIRMED-WITH-LIMITATIONS] Public pairing routes disclose the pending code and approve the sender over the real HTTP API.
[Control] Same public HTTP interface without any pending pairing entry present
GET /api/pairing -> HTTP 200, pending_count=0
POST /api/pairing/approve -> HTTP 404, ok=False, error=Pairing code not found: ABCD1234
[CONTROL-PASS] Without a pending entry, the same public API cannot approve a sender.

Impact

This is an authentication and authorization bypass on a critical account-gating workflow. Any party that can reach the TinyAGI API can approve a waiting Discord, Telegram, or WhatsApp sender without operator involvement. In practice, that defeats the sender-pairing gate and gives the newly approved sender the same downstream access that a legitimately approved channel identity would receive. The direct impact is unauthorized access to the agent-facing channel surface; the broader impact depends on what agents, tools, and files are exposed to approved senders in the deployment.

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:L/I:H/A:N

Weaknesses

  • CWE: CWE-306: Missing Authentication for Critical Function

Occurrences

Permalink Description
app.get('/api/pairing', (c) => {
const state = loadPairingState(PAIRING_FILE);
return c.json(state);
GET /api/pairing returns the full pairing state, including raw pending approval codes, to any caller without authentication.
app.post('/api/pairing/approve', async (c) => {
const body = await c.req.json() as { code?: string };
if (!body.code) {
return c.json({ ok: false, error: 'code is required' }, 400);
}
const result = approvePairingCode(PAIRING_FILE, body.code);
POST /api/pairing/approve accepts any submitted code and immediately forwards it to approvePairingCode(...) without checking caller identity or operator privileges.
const state = loadPairingState(pairingFile);
const pendingIndex = state.pending.findIndex(entry => entry.code.toUpperCase() === normalizedCode);
if (pendingIndex === -1) {
return {
ok: false,
reason: `Pairing code not found: ${normalizedCode}`,
};
}
const pending = state.pending[pendingIndex];
state.pending.splice(pendingIndex, 1);
const existingApprovedIndex = state.approved.findIndex(
entry => entry.channel === pending.channel && entry.senderId === pending.senderId
);
const approvedEntry: PairingApprovedEntry = {
channel: pending.channel,
senderId: pending.senderId,
sender: pending.sender,
approvedAt: Date.now(),
approvedCode: normalizedCode,
};
if (existingApprovedIndex >= 0) {
state.approved[existingApprovedIndex] = approvedEntry;
} else {
state.approved.push(approvedEntry);
}
savePairingState(pairingFile, state);
approvePairingCode() finds the pending entry by code, removes it from pending, writes it into approved, and persists the state change with no caller-context authorization at all.

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