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
- Download the shared helper from: common.py
- Download the verification script from: verification_test.py
- Download the control script from: control-no-pending-baseline.py
- 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/.
- 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
- 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.
- Run the baseline control:
python3 llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/control-no-pending-baseline.py
- 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. |
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/pairingand submit it toPOST /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.tstrusts API reachability as if it were operator trust:That response includes the live
pendingarray 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:
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.tsonly checks whether the submitted code matches a pending record. Once it does, the function removes the entry frompending, writes a new record intoapproved, and persists the change back topairing.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 throughPOST /api/pairing/approve. A follow-up read ofpairing.jsonshowed the same sender moved frompendingtoapproved, and the service log recorded[API] Pairing approved: discord/external-tester.GitHub now serves the canonical upstream under
TinyAGI/tinyagi(the oldTinyAGI/tinyclawpath redirects there), so the occurrences below use the canonical repository path with the exactv0.0.20commit.PoC
Prerequisites
packages/*/dist(for example, afternpm run build)Reproduction Steps
llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/.python3 llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/verification_test.pyGET /api/pairing, and successfully approves it throughPOST /api/pairing/approve.python3 llm-enhance/cve-finding/similar/authz/CVE-2026-28473-pairing-approval-api-exp/control-no-pending-baseline.py404when no pending record exists.Log of Evidence
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
Severity
Weaknesses
Occurrences
tinyagi/packages/server/src/routes/pairing.ts
Lines 11 to 13 in 1ae8b4e
GET /api/pairingreturns the full pairing state, including raw pending approval codes, to any caller without authentication.tinyagi/packages/server/src/routes/pairing.ts
Lines 17 to 22 in 1ae8b4e
POST /api/pairing/approveaccepts any submitted code and immediately forwards it toapprovePairingCode(...)without checking caller identity or operator privileges.tinyagi/packages/core/src/pairing.ts
Lines 181 to 210 in 1ae8b4e
approvePairingCode()finds the pending entry by code, removes it frompending, writes it intoapproved, and persists the state change with no caller-context authorization at all.