You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
SSRF via HTTP redirect in webhook delivery (allow_redirects not set)
Critical
mguptahub
published
GHSA-mq87-52pf-hm3hAug 3, 2026
Software
makeplane/plane
Affected versions
<= 1.3.1
Patched versions
1.4.0
Description
Summary
The Celery task that delivers Plane webhooks (apps/api/plane/bgtasks/webhook_task.py, line 337) calls requests.post() without allow_redirects=False and never re-validates the redirect target. The original webhook URL is run through validate_url(), which blocks private / loopback / link-local / reserved addresses — but the URL ultimately fetched after one or more 3xx hops is not checked. Any user who can sign up and create their own workspace (default config: ENABLE_SIGNUP=1, workspace creator is automatically ADMIN) can register a webhook to an attacker-controlled public endpoint that responds with a 302 Location: pointing at an internal address, causing the Plane worker to fetch internal resources — including cloud-metadata endpoints — and persist the response body in webhook_logs, where the same attacker can read it back through the workspace's webhook-logs API.
try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacksvalidate_url(
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
# Send the webhook eventresponse=requests.post(webhook.url, headers=headers, json=payload, timeout=30)
validate_url() (apps/api/plane/utils/ip_address.py) inspects only the originalwebhook.url.
requests.post() is called without allow_redirects=False; the default in the requests library is to follow 3xx redirects.
The redirect target is never passed back through validate_url().
save_webhook_log() (called at line 340) stores the response body verbatim in the webhook_logs table (or in MongoDB if configured) — see apps/api/plane/db/models/webhook.py (WebhookLog.response_body).
The project already implements the correct pattern elsewhere for the same class of risk: apps/api/plane/bgtasks/work_item_link_task.py::safe_get() uses allow_redirects=False plus per-hop validate_url_ip() with a MAX_REDIRECTS cap. The webhook dispatcher was not given the equivalent treatment.
Authorization model relevant to exploitability (apps/api/plane/app/views/webhook/base.py): all webhook endpoints require ROLE.ADMIN at WORKSPACE level. A freshly registered user becomes a workspace admin automatically by creating their own workspace (apps/api/plane/app/views/workspace/base.py:123-130, WorkspaceMember.objects.create(..., role=20)). With the default ENABLE_SIGNUP=1, any internet user against a default self-hosted instance can reach this code path with zero interaction with existing administrators.
Reading the exfiltrated data back: the same workspace admin can call GET /api/workspaces/<slug>/webhook-logs/<webhook_id>/ and the response includes response_body (WebhookLogSerializer.Meta.fields = "__all__"). The attacker therefore does not need any database or host access — exfiltration is fully API-driven.
PoC
video attached
plane_poc.mp4
Impact
Class: SSRF (CWE-918) bypassing an existing same-class guard via HTTP redirect, with response-body exfiltration into a log readable by the attacker through the application API.
Reachable from the worker on a default deployment:
AWS / GCP / OpenStack instance metadata at http://169.254.169.254/… and Azure IMDS — IAM role credentials, instance identity documents.
Plane's own internal API (api:8000), Postgres (plane-db:5432), MinIO (plane-minio:9000), RabbitMQ management UI (plane-mq:15672), MongoDB if configured, any other unauthenticated internal admin panel on the worker's network.
All Plane self-hosted deployments and the Plane-managed cloud, on any version up to and including the affected commit.
On AWS/GCP/Azure-hosted instances the attacker obtains IAM credentials for the worker's identity, enabling pivot into the cloud account (read/modify/delete of cloud resources accessible to that role — hence C:H / I:H / A:H on the secondary scope).
Pre-conditions: ability to authenticate to the instance and to create a workspace. On default configuration (ENABLE_SIGNUP=1), this collapses to "any internet user who can reach the Plane URL". No interaction with an existing administrator is required.
Patch: add allow_redirects=False to the requests.post(...) call at webhook_task.py:337 (minimal fix; webhooks should not follow redirects — consistent with GitHub, Stripe, Slack, etc.). If redirect-following is desired, mirror the existing safe_get() pattern from work_item_link_task.py with per-hop validate_url() and MAX_REDIRECTS.
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Learn more on MITRE.
Summary
The Celery task that delivers Plane webhooks (
apps/api/plane/bgtasks/webhook_task.py, line 337) callsrequests.post()withoutallow_redirects=Falseand never re-validates the redirect target. The original webhook URL is run throughvalidate_url(), which blocks private / loopback / link-local / reserved addresses — but the URL ultimately fetched after one or more 3xx hops is not checked. Any user who can sign up and create their own workspace (default config:ENABLE_SIGNUP=1, workspace creator is automatically ADMIN) can register a webhook to an attacker-controlled public endpoint that responds with a302 Location:pointing at an internal address, causing the Plane worker to fetch internal resources — including cloud-metadata endpoints — and persist the response body inwebhook_logs, where the same attacker can read it back through the workspace's webhook-logs API.Details
Vulnerable code —
apps/api/plane/bgtasks/webhook_task.py:328-337:validate_url()(apps/api/plane/utils/ip_address.py) inspects only the originalwebhook.url.requests.post()is called withoutallow_redirects=False; the default in therequestslibrary is to follow 3xx redirects.validate_url().save_webhook_log()(called at line 340) stores the response body verbatim in thewebhook_logstable (or in MongoDB if configured) — seeapps/api/plane/db/models/webhook.py(WebhookLog.response_body).The project already implements the correct pattern elsewhere for the same class of risk:
apps/api/plane/bgtasks/work_item_link_task.py::safe_get()usesallow_redirects=Falseplus per-hopvalidate_url_ip()with aMAX_REDIRECTScap. The webhook dispatcher was not given the equivalent treatment.Authorization model relevant to exploitability (
apps/api/plane/app/views/webhook/base.py): all webhook endpoints requireROLE.ADMINat WORKSPACE level. A freshly registered user becomes a workspace admin automatically by creating their own workspace (apps/api/plane/app/views/workspace/base.py:123-130,WorkspaceMember.objects.create(..., role=20)). With the defaultENABLE_SIGNUP=1, any internet user against a default self-hosted instance can reach this code path with zero interaction with existing administrators.Reading the exfiltrated data back: the same workspace admin can call
GET /api/workspaces/<slug>/webhook-logs/<webhook_id>/and the response includesresponse_body(WebhookLogSerializer.Meta.fields = "__all__"). The attacker therefore does not need any database or host access — exfiltration is fully API-driven.PoC
video attached
plane_poc.mp4
Impact
Class: SSRF (CWE-918) bypassing an existing same-class guard via HTTP redirect, with response-body exfiltration into a log readable by the attacker through the application API.
Reachable from the worker on a default deployment:
http://169.254.169.254/…and Azure IMDS — IAM role credentials, instance identity documents.api:8000), Postgres (plane-db:5432), MinIO (plane-minio:9000), RabbitMQ management UI (plane-mq:15672), MongoDB if configured, any other unauthenticated internal admin panel on the worker's network.::1,fe80::/10).Who is impacted:
Pre-conditions: ability to authenticate to the instance and to create a workspace. On default configuration (
ENABLE_SIGNUP=1), this collapses to "any internet user who can reach the Plane URL". No interaction with an existing administrator is required.Patch: add
allow_redirects=Falseto therequests.post(...)call atwebhook_task.py:337(minimal fix; webhooks should not follow redirects — consistent with GitHub, Stripe, Slack, etc.). If redirect-following is desired, mirror the existingsafe_get()pattern fromwork_item_link_task.pywith per-hopvalidate_url()andMAX_REDIRECTS.