From a6efc5093c9bb339a6c856d698b37897871b800a Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Tue, 23 Dec 2025 09:52:48 -0400 Subject: [PATCH] ipn/ipnlocal: add Tailscale-User-Handle header to serve proxy Add a new Tailscale-User-Handle header that provides a sanitized version of the user's login name, suitable for use as a username in applications like Forgejo/Gitea that require handles matching [\w\-_.]+. The header takes the local part of the login (before @), lowercases it, and replaces any invalid characters with hyphens. For example, "John.Doe@example.com" becomes "john.doe". This is useful because many self-hosted applications behind tailscale serve need a valid username format, and the full email in Tailscale-User-Login causes errors like: "CreateUser: name is invalid [user@domain.com]: must be valid alpha or numeric or dash(-_) or dot characters" Signed-off-by: Tobi Lutke --- ipn/ipnlocal/serve.go | 14 ++++++++++++++ ipn/ipnlocal/serve_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 4d6055bbd..ef3de235a 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -25,6 +25,7 @@ import ( "net/url" "os" "path" + "regexp" "slices" "strconv" "strings" @@ -82,6 +83,17 @@ var ErrProxyToTailscaledSocket = errors.New("cannot proxy to tailscaled socket") var serveHTTPContextKey ctxkey.Key[*serveHTTPContext] +// handleInvalidCharsRe matches any character that is not a word character, hyphen, underscore, or dot. +var handleInvalidCharsRe = regexp.MustCompile(`[^\w\-_.]+`) + +// handelizeLogin converts a login name (e.g. "John.Doe@example.com") into a +// lowercase handle with only word characters, hyphens, underscores, and dots. +// It takes the local part before '@' and replaces invalid characters with '-'. +func handelizeLogin(login string) string { + localPart := strings.Split(login, "@")[0] + return handleInvalidCharsRe.ReplaceAllString(strings.ToLower(localPart), "-") +} + type serveHTTPContext struct { SrcAddr netip.AddrPort ForVIPService tailcfg.ServiceName // "" means local @@ -1021,6 +1033,7 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) { // Clear any incoming values squatting in the headers. r.Out.Header.Del("Tailscale-User-Login") r.Out.Header.Del("Tailscale-User-Name") + r.Out.Header.Del("Tailscale-User-Handle") r.Out.Header.Del("Tailscale-User-Profile-Pic") r.Out.Header.Del("Tailscale-Funnel-Request") r.Out.Header.Del("Tailscale-Headers-Info") @@ -1044,6 +1057,7 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) { } r.Out.Header.Set("Tailscale-User-Login", encTailscaleHeaderValue(user.LoginName)) r.Out.Header.Set("Tailscale-User-Name", encTailscaleHeaderValue(user.DisplayName)) + r.Out.Header.Set("Tailscale-User-Handle", encTailscaleHeaderValue(handelizeLogin(user.LoginName))) r.Out.Header.Set("Tailscale-User-Profile-Pic", user.ProfilePicURL) r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers") } diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index 6ee2181a0..846502c73 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -751,6 +751,7 @@ func TestServeHTTPProxyHeaders(t *testing.T) { {"X-Forwarded-For", "100.150.151.152"}, {"Tailscale-User-Login", "someone@example.com"}, {"Tailscale-User-Name", "Some One"}, + {"Tailscale-User-Handle", "someone"}, {"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"}, {"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"}, }, @@ -763,6 +764,7 @@ func TestServeHTTPProxyHeaders(t *testing.T) { {"X-Forwarded-For", "100.150.151.153"}, {"Tailscale-User-Login", ""}, {"Tailscale-User-Name", ""}, + {"Tailscale-User-Handle", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, }, @@ -775,6 +777,7 @@ func TestServeHTTPProxyHeaders(t *testing.T) { {"X-Forwarded-For", "100.160.161.162"}, {"Tailscale-User-Login", ""}, {"Tailscale-User-Name", ""}, + {"Tailscale-User-Handle", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, }, @@ -806,6 +809,33 @@ func TestServeHTTPProxyHeaders(t *testing.T) { } } +func TestHandelizeLogin(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"someone@example.com", "someone"}, + {"john.doe@example.com", "john.doe"}, + {"John.Doe@example.com", "john.doe"}, + {"john-doe@example.com", "john-doe"}, + {"john_doe@example.com", "john_doe"}, + {"john123@example.com", "john123"}, + {"john+doe@example.com", "john-doe"}, + {"john!#$doe@example.com", "john-doe"}, + {"john doe@example.com", "john-doe"}, + {"jöhn@example.com", "j-hn"}, + {"someone", "someone"}, + {"@example.com", ""}, + {"John.Doe-Test_123@example.com", "john.doe-test_123"}, + } + for _, tt := range tests { + got := handelizeLogin(tt.in) + if got != tt.want { + t.Errorf("handelizeLogin(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + func TestServeHTTPProxyGrantHeader(t *testing.T) { b := newTestBackend(t) @@ -891,6 +921,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"X-Forwarded-For", "100.150.151.152"}, {"Tailscale-User-Login", "someone@example.com"}, {"Tailscale-User-Name", "Some One"}, + {"Tailscale-User-Handle", "someone"}, {"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"}, {"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"}, {"Tailscale-App-Capabilities", `{"example.com/cap/interesting":[{"role":"🐿"}]}`}, @@ -904,6 +935,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"X-Forwarded-For", "100.150.151.153"}, {"Tailscale-User-Login", ""}, {"Tailscale-User-Name", ""}, + {"Tailscale-User-Handle", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, {"Tailscale-App-Capabilities", `{"example.com/cap/boring":[{"role":"Viewer"}]}`}, @@ -917,6 +949,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"X-Forwarded-For", "100.160.161.162"}, {"Tailscale-User-Login", ""}, {"Tailscale-User-Name", ""}, + {"Tailscale-User-Handle", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, {"Tailscale-App-Capabilities", ""},