Repository navigation
Expand file tree
/
Copy pathcsrf.go
More file actions
207 lines (181 loc) · 5.72 KB
/
Copy pathcsrf.go
File metadata and controls
207 lines (181 loc) · 5.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
//go:build !go1.25
package rest
import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"
)
// CrossOriginProtection provides CSRF protection using modern browser Fetch metadata.
// It validates requests using Sec-Fetch-Site and Origin headers, rejecting cross-origin
// state-changing requests. Safe methods (GET, HEAD, OPTIONS) are always allowed.
//
// For Go 1.25+, this wraps the stdlib http.CrossOriginProtection.
// For earlier versions, it provides an equivalent custom implementation.
type CrossOriginProtection struct {
mu sync.RWMutex
trustedOrigins map[string]bool
bypassPatterns []string
denyHandler http.Handler
}
// NewCrossOriginProtection creates a new CSRF protection middleware.
func NewCrossOriginProtection() *CrossOriginProtection {
return &CrossOriginProtection{
trustedOrigins: make(map[string]bool),
}
}
// AddTrustedOrigin adds an origin that should be allowed to make cross-origin requests.
// The origin must be in the format "scheme://host" or "scheme://host:port".
// Returns an error if the origin format is invalid.
func (c *CrossOriginProtection) AddTrustedOrigin(origin string) error {
u, err := url.Parse(origin)
if err != nil {
return fmt.Errorf("invalid origin: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("origin must have scheme and host: %s", origin)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("origin must not have path: %s", origin)
}
if u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("origin must not have query or fragment: %s", origin)
}
normalized := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host)
c.mu.Lock()
c.trustedOrigins[normalized] = true
c.mu.Unlock()
return nil
}
// AddBypassPattern adds a URL pattern that should bypass CSRF protection.
// Patterns follow the same syntax as http.ServeMux (e.g., "/api/webhook", "/oauth/").
// Use sparingly and only for endpoints that have alternative authentication.
func (c *CrossOriginProtection) AddBypassPattern(pattern string) {
c.mu.Lock()
c.bypassPatterns = append(c.bypassPatterns, pattern)
c.mu.Unlock()
}
// SetDenyHandler sets a custom handler for rejected requests.
// If not set, rejected requests receive a 403 Forbidden response.
func (c *CrossOriginProtection) SetDenyHandler(h http.Handler) {
c.mu.Lock()
c.denyHandler = h
c.mu.Unlock()
}
// Check validates a request against CSRF protection rules.
// Returns nil if the request is allowed, or an error describing why it was rejected.
func (c *CrossOriginProtection) Check(r *http.Request) error {
// safe methods are always allowed
if isSafeMethod(r.Method) {
return nil
}
// check bypass patterns
if c.matchesBypassPattern(r.URL.Path) {
return nil
}
// check Sec-Fetch-Site header (modern browsers)
secFetchSite := r.Header.Get("Sec-Fetch-Site")
if secFetchSite != "" {
switch secFetchSite {
case "same-origin", "none":
return nil
case "cross-site", "same-site":
// check if origin is trusted
origin := r.Header.Get("Origin")
if origin != "" && c.isOriginTrusted(origin) {
return nil
}
return fmt.Errorf("cross-origin request blocked: Sec-Fetch-Site=%s", secFetchSite)
}
}
// fallback: check Origin header against Host
origin := r.Header.Get("Origin")
if origin != "" {
// check if origin is trusted
if c.isOriginTrusted(origin) {
return nil
}
// compare origin host with request host
originURL, err := url.Parse(origin)
if err != nil {
return fmt.Errorf("invalid Origin header: %w", err)
}
requestHost := r.Host
if requestHost == "" {
requestHost = r.URL.Host
}
// normalize hosts for comparison
originHost := strings.ToLower(originURL.Host)
requestHost = strings.ToLower(requestHost)
if originHost != requestHost {
return fmt.Errorf("cross-origin request blocked: origin %s does not match host %s", originHost, requestHost)
}
return nil
}
// no Sec-Fetch-Site or Origin headers - assume same-origin or non-browser request
return nil
}
// Handler wraps an http.Handler with CSRF protection.
// Rejected requests receive a 403 Forbidden response (or custom deny handler).
func (c *CrossOriginProtection) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := c.Check(r); err != nil {
c.mu.RLock()
deny := c.denyHandler
c.mu.RUnlock()
if deny != nil {
deny.ServeHTTP(w, r)
return
}
http.Error(w, "Forbidden - CSRF check failed", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}
// isSafeMethod returns true for HTTP methods that don't modify state.
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// isOriginTrusted checks if the origin is in the trusted list.
func (c *CrossOriginProtection) isOriginTrusted(origin string) bool {
u, err := url.Parse(origin)
if err != nil {
return false
}
normalized := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host)
c.mu.RLock()
trusted := c.trustedOrigins[normalized]
c.mu.RUnlock()
return trusted
}
// matchesBypassPattern checks if the path matches any bypass pattern.
func (c *CrossOriginProtection) matchesBypassPattern(path string) bool {
c.mu.RLock()
patterns := make([]string, len(c.bypassPatterns))
copy(patterns, c.bypassPatterns)
c.mu.RUnlock()
for _, pattern := range patterns {
if matchPattern(pattern, path) {
return true
}
}
return false
}
// matchPattern implements simple pattern matching similar to http.ServeMux.
func matchPattern(pattern, path string) bool {
// exact match
if pattern == path {
return true
}
// prefix match for patterns ending with /
if strings.HasSuffix(pattern, "/") && strings.HasPrefix(path, pattern) {
return true
}
return false
}