Skip to content

Commit 55a6488

Browse files
dolhoclaude
andauthored
fix(security): close TOCTOU race in webhook rate limiter (#644) (#696)
* fix(security): close TOCTOU race in webhook rate limiter (#644) Pre-fix path issued a separate GET then INCR. N concurrent callers could all observe count < WEBHOOK_RATE_LIMIT before any of them incremented, slipping past the 429 and pushing the actual call rate to limit + N. Switched to INCR-then-compare (Redis INCR is atomic): increment unconditionally, then 429 the caller whose post-increment count crosses the threshold. Trade-off: blocked requests still tick the counter, slightly extending cool-down for an over-limit token. Acceptable for a rate-limiter — we only stop accepting work, we don't unwind. Tests: - tests/unit/test_webhook_rate_limit_toctou.py — pins INCR-first semantics. The structural assertion (r.get() not called) reliably catches a partial revert that re-adds the GET; the wide-window race belongs in integration tests against real Redis. - tests/integration/test_webhook_rate_limit.py — adds concurrent burst test alongside the existing #589 sequential coverage. Verified live in trinity-backend with real Redis: - pre-fix: 15/20 succeeded under 20-thread burst (limit 10) — race reproduced. - post-fix: exactly 10/20 succeeded — limit holds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webhooks): unblock trigger endpoint — schedule model + audit signature (#647 follow-up) While verifying #644 against a live stack, found two additional facade gaps that #648 (the WEBHOOK-001 delegation fix) didn't catch — both crash trigger_webhook before the rate-limiter even runs to completion: 1. `Schedule` pydantic model never carried `webhook_enabled` / `webhook_token` fields. The DB columns exist, but the row mapper discarded them, so `if not schedule.webhook_enabled:` raised AttributeError on every trigger call. 2. `webhooks.py:trigger_webhook` called `platform_audit_service.log()` with `actor_type="system"`. The service derives actor_type internally from actor_user / actor_agent_name / mcp_scope and has no such kwarg; every accepted webhook 500'd in the audit step. Both are tiny: - Add the two fields to `Schedule` (db_models.py). - Pull them through `_row_to_schedule` (db/schedules.py). - Drop the bogus actor_type kwarg, pass actor_ip instead — webhook callers are unauthenticated so caller IP is the only attributable signal. With these, the integration test in tests/integration/test_webhook_rate_limit.py now exercises the full HTTP path end-to-end. Live verification against the running backend: 15-way concurrent burst → 10 × 202 + 5 × 429, limit holds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74e8d7e commit 55a6488

5 files changed

Lines changed: 412 additions & 21 deletions

File tree

src/backend/db/schedules.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ def _row_to_schedule(row) -> Schedule:
101101
# Validation configuration (VALIDATE-001)
102102
validation_enabled=bool(row["validation_enabled"]) if "validation_enabled" in row_keys and row["validation_enabled"] is not None else False,
103103
validation_prompt=row["validation_prompt"] if "validation_prompt" in row_keys else None,
104-
validation_timeout_seconds=row["validation_timeout_seconds"] if "validation_timeout_seconds" in row_keys and row["validation_timeout_seconds"] is not None else 120
104+
validation_timeout_seconds=row["validation_timeout_seconds"] if "validation_timeout_seconds" in row_keys and row["validation_timeout_seconds"] is not None else 120,
105+
# Webhook trigger (WEBHOOK-001 / #647 follow-up)
106+
webhook_enabled=bool(row["webhook_enabled"]) if "webhook_enabled" in row_keys and row["webhook_enabled"] is not None else False,
107+
webhook_token=row["webhook_token"] if "webhook_token" in row_keys else None,
105108
)
106109

107110
@staticmethod

src/backend/db_models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ class Schedule(BaseModel):
153153
validation_enabled: bool = False # Enable post-execution validation
154154
validation_prompt: Optional[str] = None # Custom auditor instructions (None = default prompt)
155155
validation_timeout_seconds: int = 120 # Timeout for validation task (30-600 range)
156+
# Webhook trigger (WEBHOOK-001 / #647 follow-up): the DB column exists and
157+
# is read by `webhooks.py:trigger_webhook`, but the pydantic model never
158+
# carried these fields — every webhook trigger raised AttributeError.
159+
webhook_enabled: bool = False
160+
webhook_token: Optional[str] = None
156161

157162

158163
class ScheduleExecution(BaseModel):

src/backend/routers/webhooks.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,25 @@ def _check_webhook_rate_limit(token: str) -> None:
207207

208208
key = f"webhook_calls:{token}"
209209
try:
210-
count = r.get(key)
211-
if count and int(count) >= WEBHOOK_RATE_LIMIT:
210+
# INCR-then-compare avoids the read-then-incr TOCTOU race (#644):
211+
# under concurrency, separate GET + INCR round-trips let N callers
212+
# all observe `count < limit` and all increment, exceeding the limit
213+
# by N. INCR is atomic in Redis, so we increment unconditionally and
214+
# 429 the caller whose post-increment count crosses the threshold.
215+
# Trade-off: blocked requests still tick the counter, slightly
216+
# extending the cool-down for an already-over-limit token. Acceptable
217+
# for a rate-limiter (we only stop accepting work, we don't unwind).
218+
pipe = r.pipeline()
219+
pipe.incr(key)
220+
pipe.expire(key, WEBHOOK_RATE_WINDOW)
221+
new_count, _ = pipe.execute()
222+
if int(new_count) > WEBHOOK_RATE_LIMIT:
212223
ttl = r.ttl(key)
213224
raise HTTPException(
214225
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
215226
detail=f"Webhook rate limit exceeded. Try again in {ttl} seconds.",
216227
headers={"Retry-After": str(max(ttl, 1))},
217228
)
218-
pipe = r.pipeline()
219-
pipe.incr(key)
220-
pipe.expire(key, WEBHOOK_RATE_WINDOW)
221-
pipe.execute()
222229
except HTTPException:
223230
raise
224231
except Exception as e:
@@ -322,12 +329,15 @@ async def trigger_webhook(
322329
detail="Scheduler service unavailable — try again later",
323330
)
324331

325-
# Audit trail (SEC-001)
332+
# Audit trail (SEC-001). Webhook callers are unauthenticated — the URL
333+
# token IS the credential — so no actor_user / actor_agent_name. The
334+
# service derives actor_type internally; passing it explicitly is a
335+
# TypeError (#647 follow-up). Caller IP is the only attributable signal.
326336
await platform_audit_service.log(
327337
event_type=AuditEventType.EXECUTION,
328338
event_action="task_triggered",
329339
source="api",
330-
actor_type="system",
340+
actor_ip=caller_ip,
331341
target_type="agent",
332342
target_id=schedule.agent_name,
333343
endpoint=f"/api/webhooks/{webhook_token[:8]}…",

tests/integration/test_webhook_rate_limit.py

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
1-
"""Issue #589 regression test for webhooks.py Redis client switch.
1+
"""Webhook rate-limit regression tests.
22
3-
The fix in src/backend/routers/webhooks.py replaced
4-
redis.Redis(host="redis", port=6379)
5-
with
6-
redis.from_url(REDIS_URL)
7-
so the credentials embedded in REDIS_URL are actually used. Without this
8-
test, a regression would silently fail-open and rate limiting would be
9-
disabled.
3+
Two scenarios covered against the live stack:
104
11-
Self-contained: creates an agent + schedule + webhook token inline so a
12-
fresh token is used (no pre-existing rate-limit state).
5+
1. **Sequential** (#589) — verifies the Redis client uses the credentialed
6+
`REDIS_URL` so rate limiting is actually engaged (the historic regression
7+
was a silent fail-open on bad auth).
8+
2. **Concurrent** (#644) — verifies the limiter is TOCTOU-safe: firing
9+
`WEBHOOK_RATE_LIMIT + 5` simultaneous requests must not let more than
10+
`WEBHOOK_RATE_LIMIT` slip through. The previous read-then-INCR path
11+
allowed each concurrent caller to observe `count < limit` before any
12+
of them incremented — so the actual call rate exceeded the budget by
13+
the concurrency factor.
1314
14-
Marked `integration` (not `smoke`) because it needs the full stack —
15+
Both tests build their own agent + schedule + webhook token so they don't
16+
share rate-limit state with each other.
17+
18+
Marked `integration` (not `smoke`) because they need the full stack —
1519
backend + Redis with auth + scheduler service. The smoke runner targets
16-
~30s and excludes Docker-dependent tests; this goes through
20+
~30s and excludes Docker-dependent tests; these go through
1721
tests/run-integration.sh.
1822
"""
1923

24+
import asyncio
2025
import uuid
2126

2227
import httpx
@@ -77,3 +82,79 @@ def test_webhook_rate_limit_returns_429_after_threshold(api_client: TrinityApiCl
7782
)
7883
finally:
7984
api_client.delete(f"/api/agents/{agent_name}")
85+
86+
87+
@pytest.mark.integration
88+
def test_webhook_rate_limit_holds_under_concurrency(api_client: TrinityApiClient):
89+
"""Concurrent regression for #644.
90+
91+
Fire `WEBHOOK_RATE_LIMIT + 5` requests simultaneously. The pre-fix
92+
read-then-INCR path could let all N callers observe `count < limit`
93+
before any incremented, exceeding the limit by N. After the
94+
INCR-then-compare fix, at most `WEBHOOK_RATE_LIMIT` calls get a
95+
non-429 response.
96+
"""
97+
agent_name = f"test-644-webhook-{uuid.uuid4().hex[:8]}"
98+
99+
create_resp = api_client.post("/api/agents", json={"name": agent_name})
100+
if create_resp.status_code not in (200, 201):
101+
pytest.skip(f"Cannot create test agent: {create_resp.text}")
102+
103+
try:
104+
sched_resp = api_client.post(
105+
f"/api/agents/{agent_name}/schedules",
106+
json={
107+
"name": f"wh-{uuid.uuid4().hex[:6]}",
108+
"cron_expression": "0 0 1 1 *", # never fires during tests
109+
"message": "noop",
110+
"enabled": True,
111+
"timezone": "UTC",
112+
},
113+
)
114+
assert sched_resp.status_code == 201, sched_resp.text
115+
sid = sched_resp.json()["id"]
116+
117+
gen_resp = api_client.post(
118+
f"/api/agents/{agent_name}/schedules/{sid}/webhook"
119+
)
120+
assert gen_resp.status_code == 200, gen_resp.text
121+
webhook_url = gen_resp.json()["webhook_url"]
122+
token = webhook_url.split("/api/webhooks/")[1]
123+
124+
url = f"http://localhost:8000/api/webhooks/{token}"
125+
n_concurrent = WEBHOOK_RATE_LIMIT + 5
126+
127+
async def fire_all():
128+
async with httpx.AsyncClient(timeout=10.0) as client:
129+
tasks = [client.post(url) for _ in range(n_concurrent)]
130+
return await asyncio.gather(*tasks, return_exceptions=True)
131+
132+
results = asyncio.run(fire_all())
133+
134+
statuses = []
135+
for r in results:
136+
if isinstance(r, Exception):
137+
pytest.fail(f"Concurrent webhook call raised: {r!r}")
138+
statuses.append(r.status_code)
139+
140+
accepted = sum(1 for s in statuses if s in (202, 503))
141+
rate_limited = sum(1 for s in statuses if s == 429)
142+
other = [s for s in statuses if s not in (202, 503, 429)]
143+
144+
assert not other, f"Unexpected statuses: {other} (full set: {statuses})"
145+
assert accepted <= WEBHOOK_RATE_LIMIT, (
146+
f"{accepted} calls succeeded under {n_concurrent}-way concurrency, "
147+
f"limit is {WEBHOOK_RATE_LIMIT}. TOCTOU race regressed: {statuses}"
148+
)
149+
# Sanity: at least some made it through (otherwise the test isn't
150+
# exercising the limiter — e.g., backend down).
151+
assert accepted >= 1, (
152+
f"No requests accepted ({statuses}) — limiter or backend broken"
153+
)
154+
# And at least one was rate-limited (proves the limiter ran).
155+
assert rate_limited >= 1, (
156+
f"No 429 in {n_concurrent}-way burst with limit {WEBHOOK_RATE_LIMIT} — "
157+
f"limiter not engaging: {statuses}"
158+
)
159+
finally:
160+
api_client.delete(f"/api/agents/{agent_name}")

0 commit comments

Comments
 (0)