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 credential phishing 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 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: Where the destination does not have to come from the request at all, map an opaque key to a server-side URL - the signed-token pattern below is the version of that for a destination which has to survive a round trip. It removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.
Where it does, 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 client-supplied and not a trustworthy redirect target. Browsers restrict scripts from setting arbitrary Referer headers, but the header can be absent, shortened by referrer policy, influenced by navigation context, or supplied by non-browser clients. Trusting it for redirect destinations can send users to unintended locations and creates inconsistent 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. Checking strings.HasPrefix(next, "/") alone is insufficient - the code 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).
Backslash Bypass of a Path Prefix Check
// VULNERABLE - a backslash passes the prefix check and resolves off-site
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 - Query().Get() decodes this to the same value
// Both send Location: /\evil.com, which the browser resolves to https://evil.com
Why this is vulnerable: The prefix check passes because the value does start with /, and url.Parse() would agree: it reports an empty Host and a path of /\evil.com. The browser disagrees. The WHATWG URL standard treats \ as equivalent to / for http and https URLs, so every browser resolves /\evil.com as protocol-relative and navigates to evil.com. This is not platform-dependent - it happens on Linux servers and Linux clients alike. The encoded spelling is the same payload rather than a second one: r.URL.Query().Get() decodes %5C before the check ever runs, and http.Redirect() writes the decoded backslash into the header unchanged. Validation has to reject the character, because parsing will not surface it.
Secure Patterns
Same-Site Path Validation
// SECURE - Validating paths for same-site redirects
package main
import (
"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 - browsers convert them to slashes when
// resolving an http(s) URL, so /\evil.com is protocol-relative to them
// even though url.Parse() reports it as a path with no host
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 accept only same-site paths. Checking for // prefix prevents protocol-relative URLs. The url.Parse() call closes a third spelling for free: it returns invalid control character in URL for anything containing a tab, CR or LF, which matters because browsers delete those characters before resolving a URL, so /%09/evil.com - decoded to a real tab by Query().Get() - would otherwise be validated as a path and read by the browser as //evil.com. Go is stricter than most parsers here; the equivalent PHP and hand-written C# checks have to reject control characters explicitly. Rejecting backslashes closes the same bypass in its other spelling: url.Parse("/\\evil.com") returns an empty Host and a path, so the parser sees nothing external, while the browser resolving that Location header reads the backslash as a separator and lands on http://evil.com/. url.Parse() separates the input into URL components so the code can verify Scheme and Host are empty. Keep the same validation close to the redirect call, because other layers that decode or rewrite paths can change how the browser interprets the final Location value.
Domain Allowlist for External Redirects
// SECURE - Allowlist of permitted redirect domains
import (
"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 hostname against allowlist and reject userinfo/ports
if parsedURL.User != nil || parsedURL.Port() != "" || !allowedRedirectDomains[parsedURL.Hostname()] {
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 admits only pre-approved destinations. It compares exact hostnames rather than substrings, so trusted-partner.com.evil.com does not match. Requiring https:// scheme prevents protocol downgrade attacks. Parsing with url.Parse() ensures the host is extracted from the URL authority rather than from path or query strings; Hostname() avoids comparing ports as part of the host. This pattern fits OAuth redirects, partner integrations, and anything else that has to redirect off-site.
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. Hostname() and not Host - Host carries the
// port, so ":8443" would be compared as part of the name and every
// non-default port on a legitimate subdomain would be refused.
if !isAllowedSubdomain(parsedURL.Hostname(), "trusted-site.com") {
http.Error(w, "Unauthorized domain", http.StatusBadRequest)
return
}
// SECURE - Reject userinfo, which puts a trusted-looking name in front of
// the host in the address bar without changing where the request goes
if parsedURL.User != nil {
http.Error(w, "Unauthorized domain", http.StatusBadRequest)
return
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
Why this works: Subdomain validation covers every subdomain of a trusted domain without listing each one. 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.
The Hostname() call is what makes the suffix comparison mean what it reads as. parsedURL.Host is the authority including the port, so comparing it against .trusted-site.com refuses https://app.trusted-site.com:8443/x - measured on Go 1.25 - while looking exactly like a working check, because every rejection test still passes. If you want to constrain ports, do it explicitly with parsedURL.Port() as the allowlist example above does; do not let the port leak into the name comparison.
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, so any change to it fails verification. The secret key is kept server-side, preventing forgery. This pattern allows storing redirect destinations in URLs without trusting the destination parameter itself. The destination is still validated as a same-site path after verification, which is required in case an unsafe destination was signed by a bug or older code path.
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"
)
// redirectGuard wraps the response writer so the check happens while the
// status line can still be changed. c.Redirect() renders during the handler,
// so a check placed after c.Next() runs against a response that has already
// been committed - see the note below.
type redirectGuard struct {
gin.ResponseWriter
blocked bool
}
// SECURE - Replace a rejected redirect with 400 and drop its Location
func (w *redirectGuard) block() {
w.blocked = true
w.Header().Del("Location")
w.ResponseWriter.WriteHeader(http.StatusBadRequest)
}
// check runs at every point where the response can still be changed. Gin's
// writer records the status instead of flushing it, so a handler can set the
// status and the Location header in either order, or in separate calls.
func (w *redirectGuard) check(status int) {
if w.blocked || w.Written() || status < 300 || status >= 400 {
return
}
if location := w.Header().Get("Location"); location != "" && !isSafeRedirect(location) {
w.block()
}
}
func (w *redirectGuard) WriteHeader(status int) {
w.check(status)
if w.blocked {
return
}
w.ResponseWriter.WriteHeader(status)
}
func (w *redirectGuard) WriteHeaderNow() {
w.check(w.Status())
w.ResponseWriter.WriteHeaderNow()
}
// Suppress the redirect body Gin writes after the status line
func (w *redirectGuard) Write(b []byte) (int, error) {
w.check(w.Status())
if w.blocked {
return len(b), nil
}
return w.ResponseWriter.Write(b)
}
func (w *redirectGuard) WriteString(s string) (int, error) {
w.check(w.Status())
if w.blocked {
return len(s), nil
}
return w.ResponseWriter.WriteString(s)
}
func validateRedirectMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
guard := &redirectGuard{ResponseWriter: c.Writer}
c.Writer = guard
c.Next()
// A handler that set the status and Location without writing a body
// has committed nothing yet - Gin flushes after the chain returns,
// so this is the last point at which that shape can be caught.
guard.check(guard.Status())
}
}
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 == "" {
// Must be a path, and not the protocol-relative form in either
// spelling - browsers read \ as a separator, so /\evil.com and
// \\evil.com both resolve to http://evil.com/
if !strings.HasPrefix(location, "/") ||
strings.HasPrefix(location, "//") ||
strings.Contains(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: Wrapping c.Writer puts the check on the path every response takes. Gin's own ResponseWriter records the status rather than flushing it, so a wrapper can still change what goes on the wire: the guard deletes the Location header, answers 400, and swallows whatever body follows. Relative URLs pass only after the same path checks isValidRedirectPath() uses above, including the backslash rejection - a middleware that repeats the parser check but drops the string checks passes /\evil.com through, because url.Parse() finds no host in it. Absolute URLs require HTTPS scheme and an allowlisted domain.
Checking in one place is not enough, because a handler can assemble a redirect in several orders. c.Redirect() sets the status first and the Location header second, so a guard that only inspects WriteHeader sees a 3xx with no destination on the first call and has to be prepared for the second. A handler writing c.Status(303) and c.Header("Location", ...) by hand commits nothing at all until Gin flushes after the chain returns. That is why check() is called from WriteHeader, WriteHeaderNow, the two write methods, and once more after c.Next() - whichever comes first for a given handler is the one that catches it. Confirmed against Gin v1.12.0 for c.Redirect(), status-then-header, header-then-status, and both manual shapes followed by a body write or an explicit flush; all answer 400 with no Location for https://evil.com, //evil.com and /\evil.com, and pass /dashboard and an allowlisted host unchanged.
The guard's scope is deliberate and worth stating: it acts on 3xx responses carrying a Location, so a 201 Created that returns a Location pointing at a new resource is left alone. It also only sees responses that go through c.Writer, so register it before any middleware that replaces the writer, and it cannot help a handler that hijacks the connection.
The obvious version of this middleware does not work, and fails silently. Calling c.Next() and then inspecting c.Writer.Status() and the Location header reads a response that is already committed: c.Redirect() renders inside the handler, so by the time the middleware resumes, the status and header are set and the body is written. c.AbortWithStatus(400) at that point changes nothing. Verified against Gin v1.12.0 - a request to /redirect?next=https://evil.com under that shape returns 303 with Location: https://evil.com, with the abort logging a warning at most. It looks like a working control in review and in a unit test that only asserts the middleware ran.
Treat this as a net rather than the fix. Handlers should still validate before calling c.Redirect(), as secureRedirectHandler() does above, so a redirect is never constructed from an unchecked value in the first place.
Common Pitfalls
- Checking only
parsedURL.Host != ""to detect an external destination, without also checkingparsedURL.Scheme != "".url.Parse()only populatesHostfrom an authority component (a//after the scheme); an opaque URI likejavascript:alert(1)parses with an emptyHostand a non-emptyScheme, so a Host-only check lets it through even thoughurl.Parse()parsed a dangerous absolute URI successfully. - Branching only on
err != nilfromurl.Parse()and skipping the field-levelScheme/Hostchecks the secure pattern above uses.url.Parse()rarely returns an error even for URLs that look malformed to a human, so error-checking alone passes most attacker-supplied strings straight through. - Validating the
next/redirectquery parameter in the primary login handler, while a separate "return to previous page" feature readsr.Referer()directly, or a reverse-proxy-aware handler builds its own redirect fromr.URL.RawQuerybefore routing - neither path is obviously "the redirect parameter," so neither goes through the same validator function.