[Hardening] Validate campaign discord_webhook_url against a Discord host allowlist (SSRF) #97

Closed
opened 2026-07-14 19:48:36 +00:00 by claude-bot · 2 comments
Contributor

Context

Stored Discord webhook URLs are POSTed server-side from the Celery worker, so any non-Discord URL stored anywhere in the system is an SSRF primitive against the deployment's internal network (cloud metadata at 169.254.169.254, localhost ports, Redis/Postgres siblings, etc.).

Write-time validation exists for the campaign path: _validate_webhook (webapp/backend/app/schemas/campaign.py:8-20) requires the https://discord.com/api/webhooks/ prefix and is applied via field_validator on both CampaignCreate (:60) and CampaignUpdate (:98), so campaign_service.py:35 receives validated data. But coverage is incomplete:

  1. Admin fallback webhook is stored verbatim, unvalidated: PUT /admin/settings/notifications (webapp/backend/app/routers/users.py:352-357) writes data.discord_webhook_url straight into settings; NotificationSettingsRequest (users.py:52) has no validator. This URL is used for every campaign without its own webhook via _effective_webhook (webapp/backend/app/services/session_service.py:28-32). Unlike the admin Whisper/LLM/bot URLs, it never passes normalize_service_url (users.py:434/446/465 use it; the webhook write at :352 does not).
  2. No send-time revalidation: the worker POSTs whatever is stored — webapp/backend/app/notifications/discord.py:91-100 (_post) and webapp/backend/app/tasks/reminder_tasks.py:874 (vote notification). Campaign rows written before the schema validator was introduced (or via any future path that bypasses the schema) are sent blind.

Fix / Spec

  1. Create one shared validator (e.g. in webapp/backend/app/services/url_policy_service.py): parse with urllib.parse.urlsplit; require scheme https, host in {discord.com, discordapp.com, ptb.discord.com, canary.discord.com}, and path starting with /api/webhooks/. Use real URL parsing, not string prefix matching, once multiple hosts are allowed.
  2. Replace the body of _validate_webhook in schemas/campaign.py with a call to the shared validator (behavioral change: the additional Discord hosts become accepted).
  3. Apply the validator to the admin settings path — either a field_validator on NotificationSettingsRequest.discord_webhook_url (users.py:52) or a check in the handler before set_setting (users.py:352-357); return 422/400 with a clear message.
  4. Re-validate immediately before each POST (defense against legacy rows): in discord.py._post (:91) and at reminder_tasks.py:874. On failure: log a clear warning identifying the campaign and skip the send — do not crash the task.
  5. Existing invalid rows need no migration; they are neutralized by step 4.

Acceptance criteria

  • Tests rejecting http://169.254.169.254/..., https://localhost:6379/..., https://evil.example/api/webhooks/x, and https://discord.com.evil.example/api/webhooks/x on: campaign create, campaign update, admin notification settings.
  • Test accepting a real https://discord.com/api/webhooks/<id>/<token> URL (and one alternate allowed host).
  • Send-time revalidation covered: a legacy-style non-Discord URL already in the DB results in a logged skip, not an outbound request and not a task crash.

References

  • webapp/backend/app/schemas/campaign.py:8-20, :60, :98 (existing campaign validator)
  • webapp/backend/app/routers/users.py:52, :352-357 (unvalidated admin fallback write)
  • webapp/backend/app/services/session_service.py:28-32 (_effective_webhook fallback resolution)
  • webapp/backend/app/notifications/discord.py:91-100, webapp/backend/app/tasks/reminder_tasks.py:874 (send sites)
  • webapp/backend/app/services/url_policy_service.py (existing URL policy home)

Filed from the July 2026 full-project review.

## Context Stored Discord webhook URLs are POSTed **server-side from the Celery worker**, so any non-Discord URL stored anywhere in the system is an SSRF primitive against the deployment's internal network (cloud metadata at 169.254.169.254, localhost ports, Redis/Postgres siblings, etc.). Write-time validation exists for the **campaign** path: `_validate_webhook` (`webapp/backend/app/schemas/campaign.py:8-20`) requires the `https://discord.com/api/webhooks/` prefix and is applied via `field_validator` on both `CampaignCreate` (`:60`) and `CampaignUpdate` (`:98`), so `campaign_service.py:35` receives validated data. But coverage is incomplete: 1. **Admin fallback webhook is stored verbatim, unvalidated**: `PUT /admin/settings/notifications` (`webapp/backend/app/routers/users.py:352-357`) writes `data.discord_webhook_url` straight into settings; `NotificationSettingsRequest` (`users.py:52`) has no validator. This URL is used for **every campaign without its own webhook** via `_effective_webhook` (`webapp/backend/app/services/session_service.py:28-32`). Unlike the admin Whisper/LLM/bot URLs, it never passes `normalize_service_url` (`users.py:434/446/465` use it; the webhook write at `:352` does not). 2. **No send-time revalidation**: the worker POSTs whatever is stored — `webapp/backend/app/notifications/discord.py:91-100` (`_post`) and `webapp/backend/app/tasks/reminder_tasks.py:874` (vote notification). Campaign rows written before the schema validator was introduced (or via any future path that bypasses the schema) are sent blind. ## Fix / Spec 1. Create one shared validator (e.g. in `webapp/backend/app/services/url_policy_service.py`): parse with `urllib.parse.urlsplit`; require scheme `https`, host in `{discord.com, discordapp.com, ptb.discord.com, canary.discord.com}`, and path starting with `/api/webhooks/`. Use real URL parsing, not string prefix matching, once multiple hosts are allowed. 2. Replace the body of `_validate_webhook` in `schemas/campaign.py` with a call to the shared validator (behavioral change: the additional Discord hosts become accepted). 3. Apply the validator to the admin settings path — either a `field_validator` on `NotificationSettingsRequest.discord_webhook_url` (`users.py:52`) or a check in the handler before `set_setting` (`users.py:352-357`); return 422/400 with a clear message. 4. Re-validate immediately before each POST (defense against legacy rows): in `discord.py._post` (`:91`) and at `reminder_tasks.py:874`. On failure: log a clear warning identifying the campaign and skip the send — do not crash the task. 5. Existing invalid rows need no migration; they are neutralized by step 4. ## Acceptance criteria - Tests rejecting `http://169.254.169.254/...`, `https://localhost:6379/...`, `https://evil.example/api/webhooks/x`, and `https://discord.com.evil.example/api/webhooks/x` on: campaign create, campaign update, admin notification settings. - Test accepting a real `https://discord.com/api/webhooks/<id>/<token>` URL (and one alternate allowed host). - Send-time revalidation covered: a legacy-style non-Discord URL already in the DB results in a logged skip, not an outbound request and not a task crash. ## References - `webapp/backend/app/schemas/campaign.py:8-20`, `:60`, `:98` (existing campaign validator) - `webapp/backend/app/routers/users.py:52`, `:352-357` (unvalidated admin fallback write) - `webapp/backend/app/services/session_service.py:28-32` (`_effective_webhook` fallback resolution) - `webapp/backend/app/notifications/discord.py:91-100`, `webapp/backend/app/tasks/reminder_tasks.py:874` (send sites) - `webapp/backend/app/services/url_policy_service.py` (existing URL policy home) _Filed from the July 2026 full-project review._
Author
Contributor

Picking this up as part of a v3.3.0 push. Landing on branch hardening/backend together with #88, #90, #106, and #109 (grouped by component to keep the diffs reviewable).

Picking this up as part of a v3.3.0 push. Landing on branch `hardening/backend` together with #88, #90, #106, and #109 (grouped by component to keep the diffs reviewable).
Author
Contributor

Fixed on main (commit 01b3a13, merged via 1c9c19f). One shared validator in url_policy_service.py (urlsplit-based — real parsing, not prefix match): requires https, exact host in {discord.com, discordapp.com, ptb.discord.com, canary.discord.com}, no embedded credentials, no off-port target, path prefix /api/webhooks/. Applied at campaign create + update (_validate_webhook) and on NotificationSettingsRequest.discord_webhook_url (empty string preserved so the admin UI can still clear the fallback). Send-time revalidation added in discord.py._post and send_vote_notification — a non-Discord URL already in the DB is logged (with campaign + host) and skipped: no outbound request, no task crash. Tests (44) cover all four rejects incl. the https://discord.com.evil.example/... suffix trap, on create/update/admin-settings, acceptance of a real URL + one alternate host, and the legacy-row logged-skip. Backend suite green (358 passed), ruff clean.

Fixed on `main` (commit `01b3a13`, merged via `1c9c19f`). One shared validator in `url_policy_service.py` (`urlsplit`-based — real parsing, not prefix match): requires `https`, exact host in `{discord.com, discordapp.com, ptb.discord.com, canary.discord.com}`, no embedded credentials, no off-port target, path prefix `/api/webhooks/`. Applied at campaign create + update (`_validate_webhook`) and on `NotificationSettingsRequest.discord_webhook_url` (empty string preserved so the admin UI can still clear the fallback). Send-time revalidation added in `discord.py._post` and `send_vote_notification` — a non-Discord URL already in the DB is logged (with campaign + host) and skipped: no outbound request, no task crash. Tests (44) cover all four rejects incl. the `https://discord.com.evil.example/...` suffix trap, on create/update/admin-settings, acceptance of a real URL + one alternate host, and the legacy-row logged-skip. Backend suite green (358 passed), ruff clean.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#97
No description provided.