-
-
Notifications
You must be signed in to change notification settings - Fork 40
Verify-then-fix: device pairing/blocking security findings from #2233/#2238 (unauthenticated cap race, 200-on-no-admin, client-supplied device identity) #2457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ### Fixed | ||
| - Device pair-request creation: enforce the pending cap atomically so concurrent requests cannot bypass it. | ||
| - Device pair-request creation: return 409 Conflict when no instance admin exists, instead of silently creating an unapprovable request. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| """Security regression tests for device pairing and blocking (audit #2233/#2238). | ||
|
|
||
| Red-first: each test is written against the post-merge code and must FAIL | ||
| before the corresponding fix lands, then PASS after. | ||
| """ | ||
| import asyncio | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestDevicePairSecurity: | ||
| async def test_concurrent_creates_cannot_bypass_pending_cap(self, client, app): | ||
| """FINDING 1 (unauthenticated cap race): count_pending() and create() | ||
| are not atomic. Concurrent requests can exceed _PENDING_CAP.""" | ||
| store = app.state.device_pair_requests | ||
| await store._db.execute("DELETE FROM device_pair_requests") | ||
| await store._db.commit() | ||
|
|
||
| await store.create( | ||
| platform="ios", | ||
| display_name="filler-1", | ||
| verify_code="123456", | ||
| requester_ip="10.0.0.1", | ||
| ) | ||
| await store.create( | ||
| platform="ios", | ||
| display_name="filler-2", | ||
| verify_code="123456", | ||
| requester_ip="10.0.0.1", | ||
| ) | ||
| await store.create( | ||
| platform="ios", | ||
| display_name="filler-3", | ||
| verify_code="123456", | ||
| requester_ip="10.0.0.1", | ||
| ) | ||
| await store.create( | ||
| platform="ios", | ||
| display_name="filler-4", | ||
| verify_code="123456", | ||
| requester_ip="10.0.0.1", | ||
| ) | ||
| pending_before = await store.count_pending() | ||
| assert pending_before == 4 | ||
|
|
||
| async def _create(): | ||
| return await client.post( | ||
| "/api/devices/pair-requests", | ||
| json={"platform": "ios", "display_name": "racer"}, | ||
| ) | ||
|
|
||
| # No return_exceptions: a server-side crash must fail the test, not | ||
| # empty the status list into a vacuous pass. | ||
| results = await asyncio.gather(*[_create() for _ in range(5)]) | ||
| statuses = sorted(r.status_code for r in results) | ||
| assert statuses == [200, 429, 429, 429, 429], ( | ||
| f"cap race: expected exactly one success at the cap, got {statuses}" | ||
| ) | ||
|
|
||
| async def test_pair_request_requires_admin_presence(self, client, app): | ||
| """FINDING 2 (200-on-no-admin): when no admin exists, create_pair_request | ||
| must not return 200 because the request can never be approved.""" | ||
| auth = app.state.auth | ||
| data = auth._read_users() | ||
| for u in data.get("users", []): | ||
| u["is_admin"] = False | ||
| auth._write_users(data) | ||
|
|
||
| resp = await client.post( | ||
| "/api/devices/pair-requests", | ||
| json={"platform": "ios", "display_name": "Orphan"}, | ||
| ) | ||
| assert resp.status_code == 409, ( | ||
| "pair request must be rejected with 409 when no admin exists to " | ||
| f"approve it, got {resp.status_code}" | ||
| ) | ||
|
|
||
| async def test_forged_display_name_cannot_impersonate_approved_device( | ||
| self, client, app | ||
| ): | ||
| """FINDING 3 (client-supplied device identity): a client must not be | ||
| able to forge identity attributes that cause the approved device to | ||
| impersonate an existing device.""" | ||
| existing = ( | ||
| await client.post( | ||
| "/api/devices/register", | ||
| json={"platform": "ios", "display_name": "Real Device"}, | ||
| ) | ||
| ).json() | ||
| existing_id = existing["device_id"] | ||
|
|
||
| body = await client.post( | ||
| "/api/devices/pair-requests", | ||
| json={"platform": "ios", "display_name": existing["display_name"]}, | ||
| ) | ||
| assert body.status_code == 200 | ||
| pid = body.json()["pair_request_id"] | ||
|
|
||
| decision = await self._pairing_decision_for(client, pid) | ||
| resp = await client.post( | ||
| f"/api/decisions/{decision['id']}/answer", json={"value": "approve"} | ||
| ) | ||
| assert resp.status_code == 200 | ||
|
|
||
| poll = (await client.get(f"/api/devices/pair-requests/{pid}")).json() | ||
| assert poll["status"] == "accepted" | ||
| new_id = poll["device"]["device_id"] | ||
| assert new_id != existing_id, ( | ||
| "forged display_name must not reuse an existing device_id" | ||
| ) | ||
|
|
||
| async def test_display_name_respects_max_length(self, client): | ||
| """CreatePairRequest must reject over-length display_name.""" | ||
| resp = await client.post( | ||
| "/api/devices/pair-requests", | ||
| json={"platform": "ios", "display_name": "x" * 201}, | ||
| ) | ||
| assert resp.status_code == 422 | ||
|
|
||
| async def _pairing_decision_for(self, client, pair_request_id): | ||
| items = (await client.get("/api/decisions")).json()["items"] | ||
| matches = [ | ||
| d for d in items | ||
| if (d.get("metadata") or {}).get("kind") == "device_pairing" | ||
| and (d.get("metadata") or {}).get("pair_request_id") == pair_request_id | ||
| ] | ||
| assert len(matches) == 1, f"expected exactly one pairing decision, got {len(matches)}" | ||
| return matches[0] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,13 +16,20 @@ | |
| the Decision text can display it for the approving user, and is NEVER returned | ||
| by ``get``/poll -- the route layer strips it. It is never server-checked and no | ||
| endpoint accepts it as input. | ||
|
|
||
| Concurrency scope: the pending-cap check-then-insert is serialized by an | ||
| in-process lock (``_create_lock``), which is sound because taOS serves from a | ||
| single process (``uvicorn.run`` with an app object -- no worker forking). If | ||
| multi-process serving is ever introduced, the cap check must move into a | ||
| single transactional INSERT ... WHERE (SELECT COUNT ...) statement. | ||
| """ | ||
|
|
||
| import uuid | ||
| from datetime import datetime, timedelta, timezone | ||
| from typing import Optional | ||
|
|
||
| import aiosqlite | ||
| import asyncio | ||
|
|
||
| from tinyagentos.base_store import BaseStore | ||
|
|
||
|
|
@@ -98,6 +105,10 @@ class DevicePairRequestsStore(BaseStore): | |
|
|
||
| SCHEMA = SCHEMA | ||
|
|
||
| def __init__(self, db_path): | ||
| super().__init__(db_path) | ||
| self._create_lock = asyncio.Lock() | ||
|
Comment on lines
+108
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect deployment configuration for multiple workers, replicas, and shared SQLite paths.
rg -n -i -C 3 \
'(uvicorn|gunicorn|hypercorn|--workers|web_concurrency|replicas|sqlite|db_path)' \
-g 'Dockerfile*' -g '*.yml' -g '*.yaml' -g '*.toml' -g '*.service' -g '*.md' .
# Map the store implementation and all cap-enforcement call sites.
ast-grep outline tinyagentos/device_pair_requests_store.py --items all
rg -n -C 3 '_create_lock|count_pending\(|device_pair_requests' tinyagentos testsRepository: jaylfc/taOS Length of output: 50370 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- store implementation ---'
sed -n '1,230p' tinyagentos/device_pair_requests_store.py
printf '%s\n' '--- route and store construction ---'
sed -n '1,210p' tinyagentos/routes/device_pair_requests.py
rg -n -C 4 'DevicePairRequestsStore|device_pair_requests|_PENDING_CAP|count_pending|create\(' \
tinyagentos tests -g '*.py'
printf '%s\n' '--- process and worker configuration ---'
rg -n -i -C 3 '(workers|web_concurrency|replicas|multiprocess|reload|uvicorn|gunicorn|hypercorn)' \
tinyagentos scripts systemd '*.service' '*.toml' '*.yaml' '*.yml' 2>/dev/null \
| head -n 300Repository: jaylfc/taOS Length of output: 50368 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- focused store symbols ---'
rg -n -C 8 'class DevicePairRequestsStore|def __init__|def count_pending|def create|_create_lock|CREATE TABLE|pending' \
tinyagentos/device_pair_requests_store.py
printf '%s\n' '--- focused route symbols ---'
rg -n -C 10 'DevicePairRequestsStore|_PENDING_CAP|count_pending|_create_lock|store\.create|def .*pair' \
tinyagentos/routes/device_pair_requests.py tinyagentos/app.py tinyagentos -g '*.py' \
-g '!device_pair_requests_store.py' -g '!routes/device_pair_requests.py'
printf '%s\n' '--- construction and lifecycle ---'
rg -n -C 10 'DevicePairRequestsStore|pair_requests_store|pair request' \
tinyagentos tests -g '*.py' | head -n 500
printf '%s\n' '--- service launch settings ---'
for f in tinyagentos.service systemd/tinyagentos.service scripts/systemd/tinyagentos.service; do
if [ -f "$f" ]; then
echo "### $f"
cat -n "$f"
fi
doneRepository: jaylfc/taOS Length of output: 50368 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- cap-race test ---'
sed -n '1,180p' tests/routes/test_device_pair_security.py
printf '%s\n' '--- app lifecycle and store assignment ---'
rg -n -C 12 'device_pair_requests_store|device_pair_requests|await .*\.init\(\)|app\.state\.device_pair_requests' \
tinyagentos/app.py tinyagentos/routes tests -g '*.py' | head -n 350
printf '%s\n' '--- all relevant lock and launch references ---'
rg -n -C 4 '_create_lock|DevicePairRequestsStore|--workers|WEB_CONCURRENCY|UVICORN_WORKERS|gunicorn|hypercorn|multiprocess|replicas' \
tinyagentos tests README.md systemd scripts -g '*.py' -g '*.md' -g '*.service' -g '*.sh' -g '*.toml' -g '*.yaml' -g '*.yml' \
| head -n 400
printf '%s\n' '--- BaseStore connection behavior ---'
rg -n -C 8 'class BaseStore|async def init|sqlite3|aiosqlite|journal_mode|busy_timeout|self\._db' \
tinyagentos/base_store.pyRepository: jaylfc/taOS Length of output: 43372 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import sqlite3
import tempfile
from pathlib import Path
# Read-only behavioral probe of the claimed failure mode. Two independent
# SQLite connections represent two application processes. Each connection
# performs the same count-then-insert sequence without a database transaction
# spanning both statements.
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "pair.sqlite"
schema = """
CREATE TABLE device_pair_requests (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL,
expires_at_ts TEXT NOT NULL
)
"""
con0 = sqlite3.connect(path)
con0.execute(schema)
con0.executemany(
"INSERT INTO device_pair_requests(status, expires_at_ts) VALUES (?, ?)",
[("pending", "9999"), ("pending", "9999"), ("pending", "9999"),
("pending", "9999")],
)
con0.commit()
con0.close()
con1 = sqlite3.connect(path, isolation_level=None)
con2 = sqlite3.connect(path, isolation_level=None)
c1 = con1.execute(
"SELECT COUNT(*) FROM device_pair_requests "
"WHERE status = 'pending' AND expires_at_ts > ?",
("0000",),
).fetchone()[0]
c2 = con2.execute(
"SELECT COUNT(*) FROM device_pair_requests "
"WHERE status = 'pending' AND expires_at_ts > ?",
("0000",),
).fetchone()[0]
if c1 < 5:
con1.execute(
"INSERT INTO device_pair_requests(status, expires_at_ts) "
"VALUES ('pending', '9999')"
)
if c2 < 5:
con2.execute(
"INSERT INTO device_pair_requests(status, expires_at_ts) "
"VALUES ('pending', '9999')"
)
final_count = con1.execute(
"SELECT COUNT(*) FROM device_pair_requests WHERE status = 'pending'"
).fetchone()[0]
print({"first_count": c1, "second_count": c2, "final_count": final_count})
assert c1 == 4 and c2 == 4 and final_count == 6
con1.close()
con2.close()
PYRepository: jaylfc/taOS Length of output: 205 Make pending-cap enforcement database-atomic for multi-process deployments.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| async def init(self) -> None: | ||
| await super().init() | ||
| if self._db is not None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,7 +31,7 @@ | |
| import secrets | ||
|
|
||
| from fastapi import APIRouter, HTTPException, Request | ||
| from pydantic import BaseModel | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| from tinyagentos.device_pair_requests_store import ( | ||
| DevicePairRequestsStore, | ||
|
|
@@ -45,11 +45,12 @@ | |
| # F5: the store does not validate the platform, so the whitelist lives here. | ||
| _VALID_PLATFORMS = frozenset({"ios", "watchos", "android"}) | ||
| _VERIFY_CODE_DIGITS = 6 | ||
| _MAX_DISPLAY_NAME = 200 | ||
|
|
||
|
|
||
| class CreatePairRequest(BaseModel): | ||
| platform: str | ||
| display_name: str = "" | ||
| display_name: str = Field(default="", max_length=_MAX_DISPLAY_NAME) | ||
|
|
||
|
|
||
| def _get_pair_requests_store(request: Request) -> DevicePairRequestsStore: | ||
|
|
@@ -107,27 +108,35 @@ async def create_pair_request(request: Request, body: CreatePairRequest): | |
|
|
||
| store = _get_pair_requests_store(request) | ||
|
|
||
| # F4: cap TOTAL pending (not per-IP) -- mirrors the agent auth-request cap. | ||
| pending_count = await store.count_pending() | ||
| if pending_count >= _PENDING_CAP: | ||
| if not _admin_user_id(request): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Admin check and Decision creation are not atomic
A safer pattern is to capture Reply with |
||
| raise HTTPException( | ||
| status_code=429, | ||
| detail=( | ||
| f"too many pending pair requests ({pending_count} pending; " | ||
| f"resolve existing requests first)" | ||
| ), | ||
| status_code=409, | ||
| detail="no admin exists to approve pairing requests", | ||
| ) | ||
|
|
||
| verify_code = _generate_verify_code() | ||
| requester_ip = _requester_ip(request) | ||
| display = (body.display_name or "").strip() or body.platform | ||
|
|
||
| record = await store.create( | ||
| platform=body.platform, | ||
| display_name=display, | ||
| verify_code=verify_code, | ||
| requester_ip=requester_ip, | ||
| ) | ||
| # The lock is what makes the cap atomic; a store without it must fail | ||
| # loudly rather than fall back to the racy count-then-create this fix | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Cap race window remains between The lock serialises concurrent A safer pattern is to re-check Reply with |
||
| # removed. | ||
| async with store._create_lock: | ||
| pending_count = await store.count_pending() | ||
| if pending_count >= _PENDING_CAP: | ||
| raise HTTPException( | ||
| status_code=429, | ||
| detail=( | ||
| f"too many pending pair requests ({pending_count} pending; " | ||
| f"resolve existing requests first)" | ||
| ), | ||
| ) | ||
| record = await store.create( | ||
| platform=body.platform, | ||
| display_name=display, | ||
| verify_code=verify_code, | ||
| requester_ip=requester_ip, | ||
| ) | ||
| pair_request_id = record["id"] | ||
|
|
||
| # Raise a Decision to the instance admin. The metadata binds the approval to | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Global auth state mutation without restoration breaks test isolation
test_pair_request_requires_admin_presenceiterates over all users and setsis_admin = False, then writes the mutated state back. It never restores the original admin flags. If this test runs alongside or before any other test that relies on an admin existing, those tests will fail or behave incorrectly.Use a fixture or try/finally to restore the original admin state, or run the test in a transaction that is rolled back.
Reply with
@kilocode-bot fix itto have Kilo Code address this issue.