Overview
The CVE-2026-41688 entry describes an SSRF risk in Wallos where an incomplete fix allowed an attacker to manipulate how outbound requests are made after validating webhook URLs. In Wallos 4.8.4 and earlier, the system validates the URL host with a DNS lookup but then uses the original hostname when performing the HTTP request, creating a DNS rebinding TOCTOU window across multiple outbound endpoints. This meant an attacker could craft a URL that initially resolves to an attacker-controlled address but later rebinds to a private or internal resource during the request, enabling SSRF to internal services. As of publication, patches for Wallos were not publicly available, underscoring how an incomplete fix can leave real systems exposed to TOCTOU-driven DNS rebinding scenarios. CWE-918 is the canonical classification for this family of issues, highlighting that trust in DNS-based resolution can be subverted when the actual connection uses a differently-resolved address than the one validated first.
Affected Versions
Wallos 4.8.4 and earlier (CVE-2026-41688)
Code Fix Example
Go (Gin) API Security Remediation
package main
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
type WebhookRequest struct {
WebhookURL string `json:"webhook_url"`
}
func main() {
r := gin.Default()
// Vulnerable handler: naive validation followed by an unconstrained outbound request
r.POST("/webhook/vulnerable", func(c *gin.Context) {
var req WebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
u, err := url.Parse(req.WebhookURL)
if err != nil || u.Scheme == "" || u.Host == "" {
c.JSON(400, gin.H{"error": "invalid URL"})
return
}
host := u.Hostname()
// naive check: just ensure hostname can be resolved
if _, err := net.LookupHost(host); err != nil {
c.JSON(400, gin.H{"error": "host not resolvable"})
return
}
// vulnerable: uses the user-supplied URL directly
resp, err := http.Get(req.WebhookURL)
if err != nil {
c.JSON(502, gin.H{"error": "failed to reach webhook"})
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
c.JSON(200, gin.H{"status": "ok"})
})
// Fixed handler: enforce allowlist, pin DNS, and bind to the resolved IP while preserving host
r.POST("/webhook/fixed", func(c *gin.Context) {
var req WebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
u, err := url.Parse(req.WebhookURL)
if err != nil || u.Scheme == "" || u.Host == "" {
c.JSON(400, gin.H{"error": "invalid URL"})
return
}
host := u.Hostname()
if !isAllowedHost(host) {
c.JSON(403, gin.H{"error": "host not allowed"})
return
}
client := newPinnedHTTPClient(host)
httpReq, err := http.NewRequest("GET", req.WebhookURL, nil)
if err != nil {
c.JSON(500, gin.H{"error": "internal"})
return
}
// Ensure the Host header matches the original host for proper virtual hosting on the target
httpReq.Host = host
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, gin.H{"error": "failed to reach webhook"})
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
c.JSON(200, gin.H{"status": "ok"})
})
r.Run(":8080")
}
func isAllowedHost(host string) bool {
// Example allowlist; in production populate from config
allowed := map[string]bool{
"example.com": true,
"internal.example.local": true,
}
return allowed[host]
}
// newPinnedHTTPClient builds an HTTP client that pins DNS resolution for the allowed host
// by resolving to IPs and dialing the IP while preserving the original host for TLS SNI/Host header.
func newPinnedHTTPClient(allowedHost string) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
host = addr
port = "80"
}
if host == allowedHost {
ips, err := net.LookupIP(host)
if err == nil && len(ips) > 0 {
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
}
tr := &http.Transport{
DialContext: dialContext,
TLSClientConfig: &tls.Config{ServerName: allowedHost},
}
return &http.Client{Transport: tr, Timeout: 10 * time.Second}
}