Skip to content

CWE-601: URL Redirection to Untrusted Site (Open Redirect) - Go

Overview

URL redirection to untrusted site vulnerabilities (Open Redirects) occur when Go web applications redirect users to URLs controlled by attackers, enabling phishing attacks, credential theft, and bypassing security controls. Redirects are common in web applications - after login (redirecting to originally requested page), after logout, in OAuth flows, or when forwarding users between application sections. When redirect destinations come from user input (query parameters, form fields, HTTP headers) without proper validation, attackers can redirect victims to malicious sites.

The attack typically works by crafting URLs like https://trusted-site.com/login?redirect=https://evil.com. After successful login, the application redirects to https://evil.com, which may be a phishing site mimicking the trusted application to steal credentials, or a site hosting malware. Users trust the redirect because it originates from the legitimate domain. Open redirects are also used to bypass security controls - URL filtering systems, OAuth redirect_uri validation, or SSRF protection may allow redirects from trusted domains without checking the final destination.

Go's http.Redirect() function performs redirects but provides no validation of the target URL. Developers must validate redirect destinations before redirecting. Common mistakes include accepting absolute URLs from query parameters, using unvalidated referrer headers, trusting OAuth or SAML parameters without validation, and failing to restrict redirects to same-origin URLs.

Primary Defence: Use allowlists for redirect destinations. For same-site redirects, validate the path starts with / and doesn't contain // or \. For external redirects, maintain an explicit allowlist of permitted domains. Parse URLs with url.Parse() and validate scheme, host, and path components. Never redirect to user-supplied absolute URLs without validation.

Common Vulnerable Patterns

Query Parameter Redirect Without Validation

// VULNERABLE - Accepting arbitrary redirect URLs
package main

import (
    "net/http"
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if authenticate(username, password) {
        // DANGEROUS: Redirecting to user-controlled URL
        redirectURL := r.URL.Query().Get("redirect")

        if redirectURL != "" {
            http.Redirect(w, r, redirectURL, http.StatusSeeOther)
            return
        }

        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    }
}

// ATTACK:
// https://trusted-bank.com/login?redirect=https://evil.com/fake-bank
// After login, user is redirected to evil.com which looks identical to trusted-bank.com
// User enters credentials again, attacker steals them

Why this is vulnerable: The application accepts any URL from the redirect query parameter and passes it directly to http.Redirect(). Attackers craft links with redirect=https://evil.com, redirecting users to attacker-controlled sites. Phishing attacks exploit this - the initial URL is legitimate (trusted-bank.com), so users trust it. After authentication, they're redirected to a fake site that mimics the real one, capturing credentials or session tokens. The browser's address bar shows the attacker's domain, but users often don't notice after clicking legitimate links.

Referrer-Based Redirect

// VULNERABLE - Using Referer header for redirect
import (
    "net/http"
)

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    // Clear session
    clearSession(r)

    // DANGEROUS: Redirecting based on Referer header
    referer := r.Header.Get("Referer")

    if referer != "" {
        http.Redirect(w, r, referer, http.StatusSeeOther)
        return
    }

    http.Redirect(w, r, "/", http.StatusSeeOther)
}

// ATTACK:
// Attacker creates page with form that submits to /logout
// Sets custom Referer header to https://evil.com
// After logout, user is redirected to attacker's site

Why this is vulnerable: The Referer header is completely controlled by the client (browser or attacker's script). Attackers can set arbitrary values using HTML forms with custom headers, JavaScript fetch() requests, or browser extensions. Trusting Referer for redirect destinations allows attackers to redirect users anywhere. While most browsers implement some Referer restrictions, relying on client-side security is insufficient. The header might also be absent (privacy extensions, HTTPS→HTTP transitions), causing unexpected behavior.

Protocol-Relative URLs

// VULNERABLE - Accepting protocol-relative URLs
import (
    "net/http"
    "strings"
)

func redirectHandler(w http.ResponseWriter, r *http.Request) {
    next := r.URL.Query().Get("next")

    // INSUFFICIENT: Only checking for same-site path
    if next != "" && strings.HasPrefix(next, "/") {
        // VULNERABLE: Allows protocol-relative URLs
        http.Redirect(w, r, next, http.StatusSeeOther)
        return
    }

    http.Redirect(w, r, "/home", http.StatusSeeOther)
}

// ATTACK:
// /redirect?next=//evil.com/fake-page
// Browser interprets //evil.com as protocol-relative URL
// Redirects to https://evil.com or http://evil.com (matching current protocol)

Why this is vulnerable: URLs starting with // are protocol-relative URLs - the browser uses the same protocol as the current page (http or https). An attacker providing //evil.com bypasses the / prefix check. The browser treats this as an external redirect to evil.com, not as a path on the current server. Simply checking strings.HasPrefix(next, "/") is insufficient - must also verify the second character isn't /. Protocol-relative URLs are valid in HTML but dangerous for redirects.

Insufficient Domain Validation

// VULNERABLE - Weak domain validation
import (
    "net/http"
    "strings"
)

func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) {
    redirectURL := r.URL.Query().Get("redirect_uri")

    // INSUFFICIENT: Substring check instead of domain validation
    if strings.Contains(redirectURL, "trusted-site.com") {
        // VULNERABLE: Allows attacker domains containing substring
        http.Redirect(w, r, redirectURL, http.StatusSeeOther)
        return
    }

    http.Error(w, "Invalid redirect", http.StatusBadRequest)
}

// ATTACK:
// redirect_uri=https://trusted-site.com.evil.com/callback
// redirect_uri=https://evil.com/page?ref=trusted-site.com
// redirect_uri=https://evil.com#trusted-site.com
// All pass the substring check but redirect to evil.com

Why this is vulnerable: strings.Contains() checks if a substring exists anywhere in the URL, not specifically in the domain component. Attackers register domains like trusted-site.com.evil.com or include the trusted domain in path/query/fragment sections. The check passes, but redirection goes to the attacker's domain. Proper validation requires parsing the URL with url.Parse() and checking the Host field exactly matches the allowed domain (or is a valid subdomain).

Path Traversal in Redirects

// VULNERABLE - Path traversal to external domains
import (
    "net/http"
    "strings"
)

func redirectTo(w http.ResponseWriter, r *http.Request) {
    path := r.URL.Query().Get("path")

    // INSUFFICIENT: Checking prefix without normalization
    if strings.HasPrefix(path, "/") {
        http.Redirect(w, r, path, http.StatusSeeOther)
        return
    }

    http.Error(w, "Invalid path", http.StatusBadRequest)
}

// ATTACK:
// /redirect?path=/\evil.com
// /redirect?path=/%5Cevil.com (URL-encoded backslash)
// On some platforms, backslash treated as path separator
// Redirects to https://evil.com

Why this is vulnerable: Different systems interpret path separators differently. While Unix uses /, Windows uses \. URL parsers and HTTP libraries may normalize \ to / or treat it as a domain separator in certain contexts. Encoded characters (%5C for \) bypass simple string checks. Attackers exploit these inconsistencies to create paths that appear valid but redirect to external domains. Proper validation requires normalizing the path and checking for malicious characters.

Secure Patterns

Same-Site Path Validation

// SECURE - Validating paths for same-site redirects
package main

import (
    "fmt"
    "net/http"
    "net/url"
    "strings"
)

func isValidRedirectPath(path string) bool {
    // Reject empty paths
    if path == "" {
        return false
    }

    // SECURE: Must start with / but not //
    if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
        return false
    }

    // SECURE: Reject backslashes (Windows path separator confusion)
    if strings.Contains(path, "\\") {
        return false
    }

    // SECURE: Parse and validate as URL
    parsedURL, err := url.Parse(path)
    if err != nil {
        return false
    }

    // SECURE: Ensure no scheme or host (must be path-only)
    if parsedURL.Scheme != "" || parsedURL.Host != "" {
        return false
    }

    return true
}

func secureRedirectHandler(w http.ResponseWriter, r *http.Request) {
    next := r.URL.Query().Get("next")

    // SECURE: Validate before redirecting
    if next != "" && isValidRedirectPath(next) {
        http.Redirect(w, r, next, http.StatusSeeOther)
        return
    }

    // Default redirect
    http.Redirect(w, r, "/home", http.StatusSeeOther)
}

Why this works: Multiple validation layers ensure only same-site paths are accepted. Checking for // prefix prevents protocol-relative URLs. Rejecting backslashes prevents Windows path separator confusion. url.Parse() normalizes the path and decodes encoded characters. Verifying Scheme and Host are empty ensures it's a path-only URL, not an absolute URL to an external site. This allows safe redirects within the application while blocking all external redirects.

Domain Allowlist for External Redirects

// SECURE - Allowlist of permitted redirect domains
import (
    "fmt"
    "net/http"
    "net/url"
)

var allowedRedirectDomains = map[string]bool{
    "trusted-partner.com":     true,
    "api.trusted-partner.com": true,
    "accounts.google.com":     true, // For OAuth
}

func isAllowedRedirectURL(redirectURL string) bool {
    // Parse URL
    parsedURL, err := url.Parse(redirectURL)
    if err != nil {
        return false
    }

    // SECURE: Only allow https:// scheme
    if parsedURL.Scheme != "https" {
        return false
    }

    // SECURE: Check host against allowlist
    if !allowedRedirectDomains[parsedURL.Host] {
        return false
    }

    return true
}

func externalRedirectHandler(w http.ResponseWriter, r *http.Request) {
    redirectURL := r.URL.Query().Get("url")

    // SECURE: Validate against allowlist
    if redirectURL != "" && isAllowedRedirectURL(redirectURL) {
        http.Redirect(w, r, redirectURL, http.StatusSeeOther)
        return
    }

    http.Error(w, "Invalid redirect URL", http.StatusBadRequest)
}

Why this works: An explicit allowlist of permitted domains provides strong security - only pre-approved destinations are allowed. The allowlist includes exact domain matches (not substring checks), preventing trusted-partner.com.evil.com bypasses. Requiring https:// scheme prevents protocol downgrade attacks. Parsing with url.Parse() ensures the domain is extracted from the proper URL component (Host field), not from path or query strings. This pattern is ideal for OAuth redirects, partner integrations, or any scenario requiring external redirects.

Subdomain Validation

// SECURE - Allowing subdomains of trusted domain
import (
    "net/http"
    "net/url"
    "strings"
)

func isAllowedSubdomain(host, baseDomain string) bool {
    // Exact match
    if host == baseDomain {
        return true
    }

    // Subdomain match
    // Host must end with ".baseDomain" to prevent evil-basedomain.com
    suffix := "." + baseDomain
    if strings.HasSuffix(host, suffix) {
        return true
    }

    return false
}

func subdomainRedirectHandler(w http.ResponseWriter, r *http.Request) {
    redirectURL := r.URL.Query().Get("redirect_uri")

    parsedURL, err := url.Parse(redirectURL)
    if err != nil {
        http.Error(w, "Invalid URL", http.StatusBadRequest)
        return
    }

    // SECURE: Require HTTPS
    if parsedURL.Scheme != "https" {
        http.Error(w, "Only HTTPS allowed", http.StatusBadRequest)
        return
    }

    // SECURE: Validate subdomain
    if !isAllowedSubdomain(parsedURL.Host, "trusted-site.com") {
        http.Error(w, "Unauthorized domain", http.StatusBadRequest)
        return
    }

    http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}

Why this works: Subdomain validation allows flexibility (multiple subdomains without listing all) while maintaining security. Checking strings.HasSuffix(host, ".baseDomain") ensures the trusted domain appears at the end of the hostname with a preceding dot, preventing evil-trusted-site.com bypasses. The exact match check handles the base domain itself. Requiring HTTPS prevents protocol downgrade. This pattern works well for multi-tenant applications or CDN setups where subdomains are dynamically created.

OAuth/SAML Redirect URI Validation

// SECURE - Strict OAuth redirect_uri validation
import (
    "crypto/subtle"
    "net/http"
    "net/url"
)

type OAuthClient struct {
    ClientID     string
    RedirectURIs []string
}

var oauthClients = map[string]*OAuthClient{
    "client123": {
        ClientID: "client123",
        RedirectURIs: []string{
            "https://app.example.com/callback",
            "https://app.example.com/oauth/callback",
        },
    },
}

func validateRedirectURI(clientID, redirectURI string) bool {
    client, exists := oauthClients[clientID]
    if !exists {
        return false
    }

    // SECURE: Exact match against registered URIs
    for _, allowedURI := range client.RedirectURIs {
        // Constant-time comparison prevents timing attacks
        if subtle.ConstantTimeCompare([]byte(redirectURI), []byte(allowedURI)) == 1 {
            return true
        }
    }

    return false
}

func oauthAuthorizeHandler(w http.ResponseWriter, r *http.Request) {
    clientID := r.URL.Query().Get("client_id")
    redirectURI := r.URL.Query().Get("redirect_uri")

    // SECURE: Validate redirect_uri exactly matches registered URI
    if !validateRedirectURI(clientID, redirectURI) {
        http.Error(w, "Invalid redirect_uri", http.StatusBadRequest)
        return
    }

    // Proceed with OAuth flow
    // Generate authorization code
    code := generateAuthCode(clientID)

    // SECURE: Redirect to validated URI with code
    redirectURL, _ := url.Parse(redirectURI)
    query := redirectURL.Query()
    query.Set("code", code)
    redirectURL.RawQuery = query.Encode()

    http.Redirect(w, r, redirectURL.String(), http.StatusSeeOther)
}

func generateAuthCode(clientID string) string {
    // Implementation: generate secure random code
    return "auth_code_123"
}

Why this works: OAuth security requires exact matching of redirect URIs against pre-registered values for each client. No partial matches, wildcards, or substring checks are allowed - this prevents attackers from registering https://app.example.com.evil.com/callback or similar. Using subtle.ConstantTimeCompare prevents timing attacks that could leak information about valid URIs. The redirect URI is validated before any authorization logic executes. The authorization code is appended as a query parameter to the validated URI, ensuring it's only sent to legitimate clients.

Signed Redirect Tokens

// SECURE - Using signed tokens for redirect destinations
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "net/http"
    "strings"
)

var redirectSecret = []byte("your-secret-key-min-32-bytes-long")

func createRedirectToken(destination string) string {
    // Create HMAC signature
    mac := hmac.New(sha256.New, redirectSecret)
    mac.Write([]byte(destination))
    signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))

    // Combine destination and signature
    return base64.URLEncoding.EncodeToString([]byte(destination)) + "." + signature
}

func validateRedirectToken(token string) (string, error) {
    // Split token and signature
    parts := strings.Split(token, ".")
    if len(parts) != 2 {
        return "", fmt.Errorf("invalid token format")
    }

    // Decode destination
    destinationBytes, err := base64.URLEncoding.DecodeString(parts[0])
    if err != nil {
        return "", err
    }
    destination := string(destinationBytes)

    // Verify signature
    mac := hmac.New(sha256.New, redirectSecret)
    mac.Write(destinationBytes)
    expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) {
        return "", fmt.Errorf("invalid signature")
    }

    return destination, nil
}

func generateLoginLink(returnTo string) string {
    // Validate return path is same-site
    if !isValidRedirectPath(returnTo) {
        returnTo = "/dashboard"
    }

    // Create signed token
    token := createRedirectToken(returnTo)

    return fmt.Sprintf("/login?token=%s", token)
}

func tokenizedLoginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if !authenticate(username, password) {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }

    // SECURE: Validate and extract destination from signed token
    token := r.URL.Query().Get("token")
    if token != "" {
        destination, err := validateRedirectToken(token)
        if err == nil && isValidRedirectPath(destination) {
            http.Redirect(w, r, destination, http.StatusSeeOther)
            return
        }
    }

    // Default redirect
    http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

func authenticate(username, password string) bool {
    return true
}

func clearSession(r *http.Request) {}

Why this works: Signed tokens prevent tampering with redirect destinations. The destination is encoded and signed with HMAC-SHA256. Users cannot modify the destination without invalidating the signature. The secret key is kept server-side, preventing forgery. This pattern allows storing redirect destinations in URLs without security risks - even if an attacker modifies the token, signature verification fails. The destination is still validated as a same-site path as defense-in-depth. This approach is more flexible than allowlists for dynamic redirect destinations while maintaining security.

Framework-Specific Guidance

Gin with Redirect Validation

// SECURE - Gin with redirect middleware
package main

import (
    "net/http"
    "net/url"
    "strings"

    "github.com/gin-gonic/gin"
)

func validateRedirectMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Process request
        c.Next()

        // Check if response is a redirect
        status := c.Writer.Status()
        if status >= 300 && status < 400 {
            location := c.Writer.Header().Get("Location")

            // SECURE: Validate redirect location
            if location != "" && !isSafeRedirect(location) {
                c.AbortWithStatus(http.StatusBadRequest)
            }
        }
    }
}

func isSafeRedirect(location string) bool {
    parsed, err := url.Parse(location)
    if err != nil {
        return false
    }

    // Allow relative URLs (same-site)
    if parsed.Scheme == "" && parsed.Host == "" {
        // Check for //
        if strings.HasPrefix(location, "//") {
            return false
        }
        return true
    }

    // Allow https:// to allowlisted domains
    if parsed.Scheme == "https" {
        allowedDomains := []string{"trusted-site.com", "www.trusted-site.com"}
        for _, domain := range allowedDomains {
            if parsed.Host == domain {
                return true
            }
        }
    }

    return false
}

func main() {
    r := gin.Default()

    // Apply redirect validation middleware
    r.Use(validateRedirectMiddleware())

    r.GET("/redirect", func(c *gin.Context) {
        next := c.Query("next")

        if next != "" {
            c.Redirect(http.StatusSeeOther, next)
            return
        }

        c.Redirect(http.StatusSeeOther, "/home")
    })

    r.Run(":8080")
}

Why this works: The middleware validates all redirects centrally, catching vulnerabilities across the application. It runs after handlers execute, inspecting the response status and Location header. Relative URLs (no scheme/host) are allowed after checking for // prefix. Absolute URLs require HTTPS scheme and allowlisted domain. This defense-in-depth approach catches redirects even if individual handlers miss validation. Gin's middleware pattern makes this easy to apply globally.

Additional Resources