Exempt task webhook trigger from session auth (#784)

POSTing to the per-task webhook URL shown in the Tasks UI returned 401
Unauthorized even though the URL is labelled "no auth needed". The
trigger handler at routes/task_routes.py:873 (`POST
/api/tasks/{task_id}/webhook/{token}`) was written as an
unauthenticated endpoint — the 32-byte path-embedded `webhook_token`
generated by `secrets.token_urlsafe(32)` is the credential, and the
handler validates it against the row before doing anything. But
AuthMiddleware in app.py runs first and only knows about
AUTH_EXEMPT_EXACT (static path set) and AUTH_EXEMPT_PREFIXES (only
`/static`), so every external POST (curl, Zapier, n8n, Make,
Activepieces) got rejected before the route ever saw the request.
External callers can't supply a session cookie, which is precisely
why the per-task token exists.

Fix: add an AUTH_EXEMPT_PATTERNS list of compiled regexes for dynamic
public paths and route `^/api/tasks/[^/]+/webhook/[^/]+/?$` through
it. The route handler still enforces `ScheduledTask.webhook_token ==
token` and 404s on mismatch, so an attacker without the token gets a
404 (indistinguishable from a non-existent task), and a holder of the
token gets the documented "POST and a task fires" behaviour. The
sibling endpoint `/{task_id}/webhook-regenerate` is admin-gated and
deliberately does NOT match the pattern — it requires `_owner(request)`
and a session.

Tests: tests/test_webhook_trigger_auth_exempt.py extracts the regex
list out of app.py, applies it to a representative trigger path
(positive) and the four neighbouring task paths that must stay
authenticated (negative — `/api/tasks`, `/api/tasks/{id}`,
`/api/tasks/{id}/webhook-regenerate`, `/api/tasks/{id}/run`), and
pins the handler-side token check so a refactor of the route doesn't
quietly turn the endpoint into a truly anonymous one.

Closes #621.
This commit is contained in:
tanmayraut45
2026-06-02 07:53:40 +05:30
committed by GitHub
parent cc40a3263e
commit 55fa223e4d
2 changed files with 112 additions and 1 deletions

18
app.py
View File

@@ -169,9 +169,25 @@ if AUTH_ENABLED:
"/login",
}
AUTH_EXEMPT_PREFIXES = ["/static"]
# Dynamic paths whose own handler proves identity via a path-embedded
# secret instead of the session/bearer auth. The route handler at
# routes/task_routes.py validates the per-task `webhook_token` itself
# and returns 404 on mismatch, so the path is the credential — the
# UI labels these URLs "no auth needed" precisely because external
# callers (Zapier, n8n, curl) can't supply a session cookie. Without
# this exemption AuthMiddleware rejects every POST with 401 before
# the token is ever checked.
import re as _re
AUTH_EXEMPT_PATTERNS = [
_re.compile(r"^/api/tasks/[^/]+/webhook/[^/]+/?$"),
]
def _is_auth_exempt(path: str) -> bool:
return path in AUTH_EXEMPT_EXACT or any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES)
if path in AUTH_EXEMPT_EXACT:
return True
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
return True
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
# In-memory token cache: prefix → list[(token_id, token_hash, owner, scopes)]. The DB
# query was running on every API-bearer request and scanning bcrypt