SSRF

SSRF in Go (Gin) Remediation [CVE-2026-41688]

[Updated May 2026] Updated CVE-2026-41688

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

CVE References

Choose which optional cookies to allow. You can change this any time.