high · 7.7Jul 14, 2026

nebula-mesh: Non-Admin SSRF via Unguarded allow_private Webhook Flag

Rohit Hatagale
AI Security Researcher, SecureLayer7

A low-privilege operator in nebula-mesh can set a single flag on their own webhook subscription to make the server send HTTP requests to loopback and private addresses, bypassing the built-in SSRF gua

Packagegithub.com/forgekeep/nebula-mesh
Ecosystemgo
Affected>= 0.6.0, <= 0.7.1
Fixed in0.7.2

The problem

In nebula-mesh versions 0.6.0 through 0.7.1, the webhook subscription endpoints (`POST` and `PATCH /api/v1/webhook-subscriptions`) accept an `allow_private` field from any authenticated operator, including those with the unprivileged `user` role.

When a subscription has `allow_private: true`, the dispatcher swaps the guarded HTTP client for an unguarded one, skipping all loopback, private, and link-local address rejection. Every other admin-level toggle in the codebase gates on `isActiveAdmin`; this field was the only exception.

An attacker can probe internal services, POST event payloads to them, and use delivery status fields (`last_status`, `last_error`) as a reachability oracle.

Proof of concept

A working proof-of-concept for this issue in github.com/forgekeep/nebula-mesh, with the exact payload below.

http
POST /api/v1/webhook-subscriptions HTTP/1.1
Authorization: Bearer <non-admin-operator-api-key>
Content-Type: application/json

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

# Server returns 201 Created with allow_private:true persisted.
# Trigger delivery by firing any lifecycle event on a host you own:
POST /api/v1/hosts/<host-id>/block HTTP/1.1
Authorization: Bearer <non-admin-operator-api-key>

# The nebula-mesh server will POST the webhook payload to 127.0.0.1:9999.

The root cause is in `internal/api/webhooks.go`: `handleCreateWebhookSubscription` (line 67) and `handleUpdateWebhookSubscription` (line 110) persist the caller-supplied `AllowPrivate` value with no role check. Only resource ownership is loosely checked on updates, and nothing at all guards the create path.

At delivery time, `internal/webhook/webhook.go` lines 294-296 branch on `tgt.AllowPrivate` to select `d.unguarded` over `d.guarded`. The unguarded client skips `config.ValidateWebhookURL`, which is the function that rejects loopback, private, and link-local targets.

The patch adds an `isActiveAdmin` gate on `AllowPrivate` at the API layer, matching the pattern already used by every other privileged field in the codebase (network create, settings PATCH, CA management). CWE-862 (Missing Authorization) is the primary root cause; the SSRF (CWE-918) is the direct consequence.

The fix

Upgrade to nebula-mesh v0.7.2, which adds an admin-role check before persisting `allow_private: true` on both the create and update paths (commit f3c54530e388dd21763e548923426e60a8e93ff0). On existing deployments, audit webhook subscriptions for non-admin-owned rows where `allow_private = true` and reset them before upgrading.

Reporter not attributed.

References: [1][2][3][4]

Related research