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…

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