Go · 2786 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package openredirect validates `next=` style redirect targets so a
4 // crafted query parameter can't bounce a logged-in user off-host
5 // (the canonical phishing primitive). The Safe predicate accepts:
6 //
7 // - relative paths starting with a single `/` (not `//`, not `/\`)
8 // - absolute URLs whose host matches an operator-approved allow-list
9 //
10 // Anything else is rejected. Callers fall back to a known-safe default
11 // (typically `/`) on rejection.
12 package openredirect
13
14 import (
15 "net/url"
16 "strings"
17 )
18
19 // Config governs which absolute redirect targets are allowed. The
20 // zero value accepts only relative paths — the safest default for
21 // most surfaces.
22 type Config struct {
23 // AllowedHosts is the exact, case-insensitive host allow-list
24 // for absolute redirect targets. Typical content: the deployment's
25 // canonical hostname plus any apex/www variants. Empty disables
26 // absolute-URL redirects entirely.
27 AllowedHosts []string
28 }
29
30 // Safe reports whether candidate is a safe redirect target under cfg.
31 // Empty input returns false; callers should pre-screen and substitute
32 // the default themselves.
33 func (cfg Config) Safe(candidate string) bool {
34 if candidate == "" {
35 return false
36 }
37 // Two-leading-slash protocol-relative URLs (`//attacker.tld/x`)
38 // are absolute under the browser's resolution; reject early so a
39 // later relative-path check doesn't accept them.
40 if strings.HasPrefix(candidate, "//") {
41 return false
42 }
43 // Reverse-slash trick (`/\evil.tld`): some browsers normalise the
44 // `\` into `/` and treat the result as protocol-relative. Block.
45 if strings.HasPrefix(candidate, "/\\") {
46 return false
47 }
48 u, err := url.Parse(candidate)
49 if err != nil {
50 return false
51 }
52 // Relative path: scheme empty AND host empty AND starts with `/`.
53 if u.Scheme == "" && u.Host == "" {
54 return strings.HasPrefix(u.Path, "/")
55 }
56 // Absolute URL: scheme must be http(s) and host must be allow-listed.
57 if u.Scheme != "http" && u.Scheme != "https" {
58 return false
59 }
60 host := u.Hostname()
61 if host == "" {
62 return false
63 }
64 for _, h := range cfg.AllowedHosts {
65 if equalFold(h, host) {
66 return true
67 }
68 }
69 return false
70 }
71
72 // SafeOr returns candidate when Safe(candidate), otherwise fallback.
73 // The single-call convenience for handlers that want to default to
74 // `/` after rejection.
75 func (cfg Config) SafeOr(candidate, fallback string) string {
76 if cfg.Safe(candidate) {
77 return candidate
78 }
79 return fallback
80 }
81
82 func equalFold(a, b string) bool {
83 if len(a) != len(b) {
84 return false
85 }
86 for i := 0; i < len(a); i++ {
87 ca, cb := a[i], b[i]
88 if 'A' <= ca && ca <= 'Z' {
89 ca += 'a' - 'A'
90 }
91 if 'A' <= cb && cb <= 'Z' {
92 cb += 'a' - 'A'
93 }
94 if ca != cb {
95 return false
96 }
97 }
98 return true
99 }
100