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:
18
app.py
18
app.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user