1
- // Package tailscaleauth provides Tailscale-aware IP authentication for Traefik
1
+ // Package tailscale_access provides Tailscale-aware IP authentication for Traefik
2
2
package tailscale_access
3
3
4
4
import (
5
5
"context"
6
6
"fmt"
7
- "log" // Using log package for more standard logging output
7
+ "log"
8
8
"net"
9
9
"net/http"
10
10
"strings"
@@ -25,7 +25,6 @@ var DefaultHeadersToCheck = []string{
25
25
"CF-Connecting-IP" , // Cloudflare
26
26
"True-Client-IP" , // Akamai and Cloudflare
27
27
"X-Client-IP" , // Common alternative
28
- // Add other headers if your specific setup uses them
29
28
}
30
29
31
30
// Config represents the configuration options for the Tailscale authentication plugin.
@@ -49,6 +48,14 @@ type Config struct {
49
48
// CustomErrorMessage is the message displayed to users when access is denied.
50
49
// Defaults to "Access denied: Tailscale connection required".
51
50
CustomErrorMessage string `json:"customErrorMessage,omitempty"`
51
+
52
+ // StrictMode, if true, only allows IPs from headers if they're Tailscale IPs.
53
+ // This prevents header spoofing attacks. Defaults to true for security.
54
+ StrictMode bool `json:"strictMode,omitempty"`
55
+
56
+ // TrustedProxies defines CIDR ranges of trusted proxies that are allowed to set forwarding headers.
57
+ // If empty, headers from any source are trusted (less secure).
58
+ TrustedProxies []string `json:"trustedProxies,omitempty"`
52
59
}
53
60
54
61
// CreateConfig creates a default plugin configuration.
@@ -59,16 +66,19 @@ func CreateConfig() *Config {
59
66
EnableDebugLogging : false ,
60
67
CustomErrorMessage : DefaultErrorMessage ,
61
68
AdditionalRanges : []string {}, // Explicitly empty by default
69
+ StrictMode : true , // Enable strict mode by default for security
70
+ TrustedProxies : []string {}, // No trusted proxies by default
62
71
}
63
72
}
64
73
65
74
// TailscaleAuth is the middleware instance.
66
75
type TailscaleAuth struct {
67
- next http.Handler
68
- name string
69
- config * Config
70
- parsedTailscaleNets []* net.IPNet
76
+ next http.Handler
77
+ name string
78
+ config * Config
79
+ parsedTailscaleNets []* net.IPNet
71
80
parsedAdditionalNets []* net.IPNet
81
+ parsedTrustedProxies []* net.IPNet
72
82
}
73
83
74
84
// New creates a new instance of the Tailscale authentication middleware.
@@ -95,13 +105,18 @@ func New(_ context.Context, next http.Handler, config *Config, name string) (htt
95
105
return nil , fmt .Errorf ("failed to parse additionalRanges: %w" , err )
96
106
}
97
107
108
+ parsedTrustedProxies , err := parseCIDRs (config .TrustedProxies )
109
+ if err != nil {
110
+ return nil , fmt .Errorf ("failed to parse trustedProxies: %w" , err )
111
+ }
112
+
98
113
if len (parsedTailscaleNets ) == 0 && len (parsedAdditionalNets ) == 0 {
99
114
return nil , fmt .Errorf ("tailscale-auth plugin misconfiguration: at least one valid TailscaleRange or AdditionalRange must be provided" )
100
115
}
101
116
102
117
if config .EnableDebugLogging {
103
- log .Printf ("[INFO] TailscaleAuth plugin '%s' initialized. Tailscale Ranges: %v, Additional Ranges: %v, Headers: %v" ,
104
- name , config .TailscaleRanges , config .AdditionalRanges , config .HeadersToCheck )
118
+ log .Printf ("[INFO] TailscaleAuth plugin '%s' initialized. Tailscale Ranges: %v, Additional Ranges: %v, Headers: %v, StrictMode: %v " ,
119
+ name , config .TailscaleRanges , config .AdditionalRanges , config .HeadersToCheck , config . StrictMode )
105
120
}
106
121
107
122
return & TailscaleAuth {
@@ -110,6 +125,7 @@ func New(_ context.Context, next http.Handler, config *Config, name string) (htt
110
125
config : config ,
111
126
parsedTailscaleNets : parsedTailscaleNets ,
112
127
parsedAdditionalNets : parsedAdditionalNets ,
128
+ parsedTrustedProxies : parsedTrustedProxies ,
113
129
}, nil
114
130
}
115
131
@@ -131,106 +147,129 @@ func parseCIDRs(cidrStrings []string) ([]*net.IPNet, error) {
131
147
132
148
// ServeHTTP processes each incoming request, checking for Tailscale IP authentication.
133
149
func (t * TailscaleAuth ) ServeHTTP (rw http.ResponseWriter , req * http.Request ) {
134
- clientIPStr := t .extractClientIP (req )
135
- if clientIPStr == "" {
136
- if t .config .EnableDebugLogging {
137
- log .Printf ("[DEBUG] TailscaleAuth '%s': No client IP found for request to %s" , t .name , req .URL .Path )
138
- }
139
- t .denyAccess (rw )
140
- return
141
- }
142
-
143
- clientIP := net .ParseIP (clientIPStr )
150
+ clientIP , source := t .extractClientIP (req )
144
151
if clientIP == nil {
145
152
if t .config .EnableDebugLogging {
146
- log .Printf ("[DEBUG] TailscaleAuth '%s': Invalid client IP format '%s' for request to %s" , t .name , clientIPStr , req .URL .Path )
153
+ log .Printf ("[DEBUG] TailscaleAuth '%s': No valid client IP found for request to %s" , t .name , req .URL .Path )
147
154
}
148
155
t .denyAccess (rw )
149
156
return
150
157
}
151
158
152
159
if t .config .EnableDebugLogging {
153
- log .Printf ("[DEBUG] TailscaleAuth '%s': Checking IP %s (parsed from %s) for request to %s" , t .name , clientIP .String (), clientIPStr , req .URL .Path )
160
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Checking IP %s (from %s) for request to %s" , t .name , clientIP .String (), source , req .URL .Path )
154
161
}
155
162
156
163
if t .isIPAllowed (clientIP ) {
157
164
if t .config .EnableDebugLogging {
158
- log .Printf ("[DEBUG] TailscaleAuth '%s': Allowing access for IP %s" , t .name , clientIP .String ())
165
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Allowing access for IP %s (from %s) " , t .name , clientIP .String (), source )
159
166
}
160
167
t .next .ServeHTTP (rw , req )
161
168
return
162
169
}
163
170
164
171
if t .config .EnableDebugLogging {
165
- log .Printf ("[DEBUG] TailscaleAuth '%s': Blocking access for IP %s" , t .name , clientIP .String ())
172
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Blocking access for IP %s (from %s) " , t .name , clientIP .String (), source )
166
173
}
167
174
t .denyAccess (rw )
168
175
}
169
176
170
- // extractClientIP attempts to find the client's real IP address.
171
- // It checks specified HTTP headers first, then falls back to the request's RemoteAddr.
172
- func (t * TailscaleAuth ) extractClientIP (req * http.Request ) string {
173
- // Check headers first
177
+ // extractClientIP attempts to find the client's real IP address with improved security.
178
+ // Returns the IP and a description of where it was found.
179
+ func (t * TailscaleAuth ) extractClientIP (req * http.Request ) (net.IP , string ) {
180
+ // Get the direct connection IP first
181
+ directIP , _ , err := net .SplitHostPort (req .RemoteAddr )
182
+ if err != nil {
183
+ // If RemoteAddr is not in host:port format, use it as is
184
+ directIP = strings .TrimSpace (req .RemoteAddr )
185
+ }
186
+ directIPParsed := net .ParseIP (strings .TrimSpace (directIP ))
187
+
188
+ // Check if we should trust headers from this direct connection
189
+ trustHeaders := len (t .parsedTrustedProxies ) == 0 || // No trusted proxies configured (trust all)
190
+ (directIPParsed != nil && t .isIPInSpecificNets (directIPParsed , t .parsedTrustedProxies ))
191
+
192
+ if ! trustHeaders {
193
+ if t .config .EnableDebugLogging {
194
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Direct IP %s is not in trusted proxies, ignoring headers" , t .name , directIP )
195
+ }
196
+ return directIPParsed , "RemoteAddr (untrusted proxy)"
197
+ }
198
+
199
+ // Check headers for forwarded IPs
174
200
for _ , headerName := range t .config .HeadersToCheck {
175
201
headerValue := req .Header .Get (headerName )
176
202
if headerValue == "" {
177
203
continue
178
204
}
179
205
180
- // Headers like X-Forwarded-For can contain a list of IPs.
181
- // We are interested in the first *valid* IP in the list that might be a Tailscale IP.
182
- // Typically, the first IP in XFF is the original client.
206
+ if t .config .EnableDebugLogging {
207
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Checking header %s: %s" , t .name , headerName , headerValue )
208
+ }
209
+
210
+ // Headers like X-Forwarded-For can contain a list of IPs
183
211
ips := strings .Split (headerValue , "," )
184
- for _ , ipStr := range ips {
212
+ for i , ipStr := range ips {
185
213
trimmedIP := strings .TrimSpace (ipStr )
186
- // Quick check: if this IP is in a Tailscale range, use it.
187
- // This avoids unnecessary parsing of RemoteAddr if a header IP is already confirmed.
188
- // Note: We re-parse here, but the primary check is in isIPAllowed with parsed nets.
189
- // This is a heuristic to pick the "best" IP from headers.
190
- if net .ParseIP (trimmedIP ) != nil { // Ensure it's a valid IP format before deeper checks
191
- if t .isIPInSpecificNets (net .ParseIP (trimmedIP ), t .parsedTailscaleNets ) {
214
+ parsedIP := net .ParseIP (trimmedIP )
215
+ if parsedIP == nil {
216
+ continue // Skip invalid IPs
217
+ }
218
+
219
+ // In strict mode, only return header IPs if they're in Tailscale ranges
220
+ if t .config .StrictMode {
221
+ if t .isIPInSpecificNets (parsedIP , t .parsedTailscaleNets ) {
192
222
if t .config .EnableDebugLogging {
193
- log .Printf ("[DEBUG] TailscaleAuth '%s': Found potential Tailscale IP %s in header %s: %s " , t .name , trimmedIP , headerName , headerValue )
223
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Found Tailscale IP %s in header %s (position %d) " , t .name , trimmedIP , headerName , i )
194
224
}
195
- return trimmedIP // Found a Tailscale IP in a header
225
+ return parsedIP , fmt . Sprintf ( "header %s (strict mode)" , headerName )
196
226
}
197
- // If not a Tailscale IP, but it's the first one in the list, it's a candidate.
198
- // The actual decision will be made by isIPAllowed.
199
- // We prefer the first IP from the first populated header.
200
- if t . config . EnableDebugLogging {
201
- log . Printf ( "[DEBUG] TailscaleAuth '%s': Using first IP %s from header %s: %s as candidate" , t . name , trimmedIP , headerName , headerValue )
202
- }
203
- return trimmedIP
227
+ // In strict mode, continue looking for Tailscale IPs
228
+ continue
229
+ }
230
+
231
+ // In non-strict mode, return the first valid IP (for backward compatibility )
232
+ if t . config . EnableDebugLogging {
233
+ log . Printf ( "[DEBUG] TailscaleAuth '%s': Using IP %s from header %s (position %d, non-strict mode)" , t . name , trimmedIP , headerName , i )
204
234
}
235
+ return parsedIP , fmt .Sprintf ("header %s (non-strict mode)" , headerName )
205
236
}
206
237
}
207
238
208
- // Fallback to RemoteAddr if no suitable IP found in headers
209
- directIP , _ , err := net .SplitHostPort (req .RemoteAddr )
210
- if err != nil {
211
- // If RemoteAddr is not in host:port format, use it as is (could be just an IP)
212
- if t .config .EnableDebugLogging && req .RemoteAddr != "" {
213
- log .Printf ("[DEBUG] TailscaleAuth '%s': Could not split host/port for RemoteAddr '%s', using as is. Error: %v" , t .name , req .RemoteAddr , err )
239
+ // Fallback to direct connection IP
240
+ if directIPParsed != nil {
241
+ if t .config .EnableDebugLogging {
242
+ log .Printf ("[DEBUG] TailscaleAuth '%s': Using direct connection IP %s" , t .name , directIP )
214
243
}
215
- return strings . TrimSpace ( req . RemoteAddr )
244
+ return directIPParsed , " RemoteAddr (fallback)"
216
245
}
217
- if t .config .EnableDebugLogging && directIP != "" {
218
- log .Printf ("[DEBUG] TailscaleAuth '%s': Using IP %s from RemoteAddr" , t .name , directIP )
219
- }
220
- return strings .TrimSpace (directIP )
246
+
247
+ return nil , "none found"
221
248
}
222
249
223
250
// isIPAllowed checks if the given IP address is present in any of the configured Tailscale or additional CIDR ranges.
224
251
func (t * TailscaleAuth ) isIPAllowed (ip net.IP ) bool {
225
252
if ip == nil {
226
253
return false
227
254
}
255
+
228
256
// Check against Tailscale ranges first
229
257
if t .isIPInSpecificNets (ip , t .parsedTailscaleNets ) {
258
+ if t .config .EnableDebugLogging {
259
+ log .Printf ("[DEBUG] TailscaleAuth '%s': IP %s matches Tailscale range" , t .name , ip .String ())
260
+ }
230
261
return true
231
262
}
263
+
232
264
// Then check against additional allowed ranges
233
- return t .isIPInSpecificNets (ip , t .parsedAdditionalNets )
265
+ if t .isIPInSpecificNets (ip , t .parsedAdditionalNets ) {
266
+ if t .config .EnableDebugLogging {
267
+ log .Printf ("[DEBUG] TailscaleAuth '%s': IP %s matches additional range" , t .name , ip .String ())
268
+ }
269
+ return true
270
+ }
271
+
272
+ return false
234
273
}
235
274
236
275
// isIPInSpecificNets checks if an IP is contained within any of the provided IP networks.
0 commit comments