Skip to content

Commit 91b0714

Browse files
committed
update
1 parent 9d257a3 commit 91b0714

File tree

2 files changed

+467
-213
lines changed

2 files changed

+467
-213
lines changed

tailscale-access.go

Lines changed: 97 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// Package tailscaleauth provides Tailscale-aware IP authentication for Traefik
1+
// Package tailscale_access provides Tailscale-aware IP authentication for Traefik
22
package tailscale_access
33

44
import (
55
"context"
66
"fmt"
7-
"log" // Using log package for more standard logging output
7+
"log"
88
"net"
99
"net/http"
1010
"strings"
@@ -25,7 +25,6 @@ var DefaultHeadersToCheck = []string{
2525
"CF-Connecting-IP", // Cloudflare
2626
"True-Client-IP", // Akamai and Cloudflare
2727
"X-Client-IP", // Common alternative
28-
// Add other headers if your specific setup uses them
2928
}
3029

3130
// Config represents the configuration options for the Tailscale authentication plugin.
@@ -49,6 +48,14 @@ type Config struct {
4948
// CustomErrorMessage is the message displayed to users when access is denied.
5049
// Defaults to "Access denied: Tailscale connection required".
5150
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"`
5259
}
5360

5461
// CreateConfig creates a default plugin configuration.
@@ -59,16 +66,19 @@ func CreateConfig() *Config {
5966
EnableDebugLogging: false,
6067
CustomErrorMessage: DefaultErrorMessage,
6168
AdditionalRanges: []string{}, // Explicitly empty by default
69+
StrictMode: true, // Enable strict mode by default for security
70+
TrustedProxies: []string{}, // No trusted proxies by default
6271
}
6372
}
6473

6574
// TailscaleAuth is the middleware instance.
6675
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
7180
parsedAdditionalNets []*net.IPNet
81+
parsedTrustedProxies []*net.IPNet
7282
}
7383

7484
// 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
95105
return nil, fmt.Errorf("failed to parse additionalRanges: %w", err)
96106
}
97107

108+
parsedTrustedProxies, err := parseCIDRs(config.TrustedProxies)
109+
if err != nil {
110+
return nil, fmt.Errorf("failed to parse trustedProxies: %w", err)
111+
}
112+
98113
if len(parsedTailscaleNets) == 0 && len(parsedAdditionalNets) == 0 {
99114
return nil, fmt.Errorf("tailscale-auth plugin misconfiguration: at least one valid TailscaleRange or AdditionalRange must be provided")
100115
}
101116

102117
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)
105120
}
106121

107122
return &TailscaleAuth{
@@ -110,6 +125,7 @@ func New(_ context.Context, next http.Handler, config *Config, name string) (htt
110125
config: config,
111126
parsedTailscaleNets: parsedTailscaleNets,
112127
parsedAdditionalNets: parsedAdditionalNets,
128+
parsedTrustedProxies: parsedTrustedProxies,
113129
}, nil
114130
}
115131

@@ -131,106 +147,129 @@ func parseCIDRs(cidrStrings []string) ([]*net.IPNet, error) {
131147

132148
// ServeHTTP processes each incoming request, checking for Tailscale IP authentication.
133149
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)
144151
if clientIP == nil {
145152
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)
147154
}
148155
t.denyAccess(rw)
149156
return
150157
}
151158

152159
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)
154161
}
155162

156163
if t.isIPAllowed(clientIP) {
157164
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)
159166
}
160167
t.next.ServeHTTP(rw, req)
161168
return
162169
}
163170

164171
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)
166173
}
167174
t.denyAccess(rw)
168175
}
169176

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
174200
for _, headerName := range t.config.HeadersToCheck {
175201
headerValue := req.Header.Get(headerName)
176202
if headerValue == "" {
177203
continue
178204
}
179205

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
183211
ips := strings.Split(headerValue, ",")
184-
for _, ipStr := range ips {
212+
for i, ipStr := range ips {
185213
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) {
192222
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)
194224
}
195-
return trimmedIP // Found a Tailscale IP in a header
225+
return parsedIP, fmt.Sprintf("header %s (strict mode)", headerName)
196226
}
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)
204234
}
235+
return parsedIP, fmt.Sprintf("header %s (non-strict mode)", headerName)
205236
}
206237
}
207238

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)
214243
}
215-
return strings.TrimSpace(req.RemoteAddr)
244+
return directIPParsed, "RemoteAddr (fallback)"
216245
}
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"
221248
}
222249

223250
// isIPAllowed checks if the given IP address is present in any of the configured Tailscale or additional CIDR ranges.
224251
func (t *TailscaleAuth) isIPAllowed(ip net.IP) bool {
225252
if ip == nil {
226253
return false
227254
}
255+
228256
// Check against Tailscale ranges first
229257
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+
}
230261
return true
231262
}
263+
232264
// 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
234273
}
235274

236275
// isIPInSpecificNets checks if an IP is contained within any of the provided IP networks.

0 commit comments

Comments
 (0)