auth: decode both Tailscale headers and require the name
Serve writes both headers Q-encoded, name and login the same,
so both are decoded now, and a 403 answers a missing name.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index 43c809b..50352d0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,8 +80,9 @@ not a fixture; edit a `.vcf` to change what the replay serves.
- Response bodies are built with heredocs in `web.rb`. A comment written
inside one is sent to the client — keep notes about the code in Ruby
comments outside the heredoc.
-- Every request needs a `Tailscale-User-Login` header or it gets a 403, so
- a bare `curl` against `rake dev` is refused until you pass one. The
+- Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
+ headers or it gets a 403, so a bare `curl` against `rake dev` is
+ refused until you pass both. The
security of that rests on the app being reachable only through
`tailscale serve` — never bind it to anything but localhost.
- Unanswered requests (404s, app-level 403s, and 5xx) are written to
diff --git a/README.md b/README.md
index c66393f..cab5438 100644
--- a/README.md
+++ b/README.md
@@ -57,9 +57,10 @@ PUT stores the submitted card verbatim (RFC 6352 section 6.3.2), and the
change log the sync tokens count on is written with it, in one
transaction.
-Requests are authenticated by the `Tailscale-User-Login` header that
-`tailscale serve` injects, which it strips from incoming requests so a
-client cannot forge one. A request without it gets a 403. That holds only
+Requests are authenticated by the `Tailscale-User-Login` and
+`Tailscale-User-Name` headers that `tailscale serve` injects, which it
+strips from incoming requests so a client cannot forge them. A request
+without both gets a 403. That holds only
while the app is reachable through serve alone — bind it to localhost.
Tailscale documents two cases that carry no identity and so cannot get in:
Funnel traffic, which is public, and traffic from tagged devices.
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
index b0360bc..5150ff4 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -1,27 +1,83 @@
module ProTacts
# Gates every request on the identity headers Tailscale serve injects.
#
- # Serve sets Tailscale-User-Login from the tailnet identity of the calling
- # node, and strips the header from incoming requests before proxying so a
- # client cannot supply its own. That makes the header trustworthy, but only
- # behind serve: reached directly, this middleware trusts whatever it is
- # handed. The app must not be listening anywhere but localhost.
+ # Serve sets Tailscale-User-Login and Tailscale-User-Name from the
+ # tailnet identity of the calling node, and strips both from incoming
+ # requests before proxying so a client cannot supply its own. That makes
+ # the headers trustworthy, but only behind serve: reached directly, this
+ # middleware trusts whatever it is handed. The app must not be listening
+ # anywhere but localhost.
#
# Failing closed covers the two cases Tailscale documents as having no
# identity: Funnel traffic, which is public, and traffic from tagged
# devices. A family device that gets tagged will start seeing 403s.
#
# Any tailnet identity is accepted. Getting onto the tailnet is the access
- # control; the address book has no per-user view to protect.
+ # control; the name only picks which address book a client syncs
+ # (docs/plans/2026-09-12-per-user-books.md).
class TailscaleAuth
# @rbs @app: Rack::_App
LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
- # Where the authenticated login lands for anything downstream that wants
- # to know who is asking.
- IDENTITY = "pro_tacts.user"
+ # Who is asking, both headers decoded. The signature is in
+ # sig/pro_tacts/tailscale_auth.rbs.
+ # @rbs skip
+ Identity = Data.define(:login, :name)
+
+ # The identity on a request, or nil for one that names nobody. The
+ # gate below asks it to refuse a request, and the app asks it again
+ # to learn who got through.
+ #: (Rack::env env) -> Identity?
+ def self.identity(env)
+ login = decode(env[LOGIN_HEADER].to_s)&.strip
+ name = decode(env[NAME_HEADER].to_s)&.strip
+ return if login.nil? || login.empty? || name.nil? || name.empty?
+
+ Identity.new(login:, name:)
+ end
+
+ # One RFC 2047 encoded-word (section 2), and the whitespace after it
+ # when another follows: section 6.2 has that whitespace dropped, so a
+ # long value split across words decodes whole.
+ ENCODED_WORD = /=\?([^?\s]+)\?([QqBb])\?([^?\s]+)\?=/ #: Regexp
+ ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
+ private_constant :ENCODED_WORD, :ENCODED_RUN
+
+ # A header value as serve writes it: Go's mime.QEncoding over the
+ # UTF-8 value (encTailscaleHeaderValue in tailscale's
+ # ipn/ipnlocal/serve.go), which leaves an ASCII value as it is. Nil
+ # for anything that does not decode to UTF-8 — a charset other than
+ # UTF-8 included, since serve never writes one.
+ #: (String value) -> String?
+ def self.decode(value)
+ decoded = value.dup.force_encoding(Encoding::UTF_8).gsub(ENCODED_RUN) {
+ charset, encoding, text = $1.to_s, $2.to_s, $3.to_s
+ return nil unless charset.casecmp?("utf-8")
+
+ decode_word(encoding, text) or return nil
+ }
+ decoded if decoded.valid_encoding?
+ end
+
+ # Q is section 4.2, whose underscore is a space whatever else the
+ # text holds; B is section 4.1's base64, strict so a malformed word
+ # is refused rather than read as whatever part of it decodes.
+ #: (String encoding, String text) -> String?
+ def self.decode_word(encoding, text)
+ bytes =
+ if encoding.casecmp?("q")
+ text.tr("_", " ").unpack1("M")
+ else
+ text.unpack1("m0")
+ end #: String
+ bytes.force_encoding(Encoding::UTF_8)
+ rescue ArgumentError
+ # unpack1("m0")'s refusal of text that is not base64.
+ nil
+ end
+ private_class_method :decode_word
#: (Rack::_App app) -> void
def initialize(app)
@@ -30,13 +86,10 @@ module ProTacts
#: (Rack::env env) -> Rack::response
def call(env)
- login = env[LOGIN_HEADER].to_s.strip
-
# 403 rather than 401: no credentials the client could supply would
# help, so there is no challenge worth sending.
- return forbidden if login.empty?
+ return forbidden if self.class.identity(env).nil?
- env[IDENTITY] = login
@app.call(env)
end
diff --git a/sig/pro_tacts/tailscale_auth.rbs b/sig/pro_tacts/tailscale_auth.rbs
new file mode 100644
index 0000000..843e353
--- /dev/null
+++ b/sig/pro_tacts/tailscale_auth.rbs
@@ -0,0 +1,13 @@
+# Identity is a Data class, whose members the inline parser cannot read
+# (see sig/pro_tacts/contact.rbs). TailscaleAuth itself is an ordinary
+# class and its types are inline in tailscale_auth.rb.
+module ProTacts
+ class TailscaleAuth
+ class Identity
+ attr_reader login: String
+ attr_reader name: String
+
+ def self.new: (login: String, name: String) -> instance
+ end
+ end
+end
diff --git a/tasks/probe.rake b/tasks/probe.rake
index 1a4e425..7d7419d 100644
--- a/tasks/probe.rake
+++ b/tasks/probe.rake
@@ -22,11 +22,12 @@ namespace :probe do
request = Net::HTTP::Put.new(url)
request["Content-Type"] = "text/vcard"
- # Serve strips this header from incoming requests and sets it from
- # the tailnet identity, so supplying one is only possible — and only
- # meaningful — against a dev server on localhost
+ # Serve strips these headers from incoming requests and sets them
+ # from the tailnet identity, so supplying them is only possible — and
+ # only meaningful — against a dev server on localhost
# (ProTacts::TailscaleAuth).
request["Tailscale-User-Login"] = ENV.fetch("PROBE_LOGIN", "probe@example.com")
+ request["Tailscale-User-Name"] = ENV.fetch("PROBE_NAME", "Probe")
request.body = card
response = Net::HTTP.start(url.hostname, url.port) { it.request(request) }
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 5206828..f90c191 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -22,6 +22,7 @@ class AdminContactsPagesTest < Minitest::Test
def setup
header "Tailscale-User-Login", "test@example.com"
+ header "Tailscale-User-Name", "Test User"
end
ADA = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 8062093..92e82ef 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -18,6 +18,7 @@ class AdminDeviceSetupTest < Minitest::Test
def setup
header "Tailscale-User-Login", "test@example.com"
+ header "Tailscale-User-Name", "Test User"
header "Host", "box.example.ts.net"
end
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
index f118d1c..91fea5d 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -18,6 +18,7 @@ class AdminFooterTest < Minitest::Test
def setup
header "Tailscale-User-Login", "test@example.com"
+ header "Tailscale-User-Name", "Test User"
@config = ProTacts.config
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index ce736c3..2b04e29 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -17,6 +17,7 @@ class AdminGroupsPagesTest < Minitest::Test
def setup
header "Tailscale-User-Login", "test@example.com"
+ header "Tailscale-User-Name", "Test User"
end
ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index 5c511f4..8d61ee1 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -23,11 +23,12 @@ class ExchangeFixtures < Data.define(:directory)
# Date, server) is plumbing.
RESPONSE_HEADERS = %w[Content-Type ETag DAV Allow Location].freeze
- # Stands in for the Tailscale-User-Login the recorded sessions carried.
- # It was stripped from the request files because it names a real tailnet
- # user; the app now refuses requests without one, so the replay has to
- # put an identity back.
+ # Stand in for the Tailscale identity headers the recorded sessions
+ # carried. They were stripped from the request files because they name
+ # a real tailnet user; the app refuses requests without them, so the
+ # replay has to put an identity back.
REPLAY_LOGIN = "replay@example.com"
+ REPLAY_NAME = "Replay"
# Every recording, for the callers that run all of them.
def self.all
@@ -58,7 +59,10 @@ class ExchangeFixtures < Data.define(:directory)
step.headers.to_h { |name, value|
key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
[key, value]
- }.merge(ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN)
+ }.merge(
+ ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN,
+ ProTacts::TailscaleAuth::NAME_HEADER => REPLAY_NAME,
+ )
end
# Replays every recorded request against the app and rewrites the
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
index 64b0242..ec3fcce 100644
--- a/test/pro_tacts/test_tailscale_auth.rb
+++ b/test/pro_tacts/test_tailscale_auth.rb
@@ -24,8 +24,17 @@ class TailscaleAuthTest < Minitest::Test
attr_reader :app
+ def identify(login: "alpha@example.com", name: "Alpha Chen")
+ header "Tailscale-User-Login", login
+ header "Tailscale-User-Name", name
+ end
+
+ def identity
+ ProTacts::TailscaleAuth.identity(@spy.env)
+ end
+
def test_request_with_an_identity_reaches_the_app
- header "Tailscale-User-Login", "alpha@example.com"
+ identify
get "/"
@@ -33,12 +42,12 @@ class TailscaleAuthTest < Minitest::Test
assert_equal "reached the app", last_response.body
end
- def test_identity_is_available_to_the_app
- header "Tailscale-User-Login", "alpha@example.com"
+ def test_the_app_can_read_who_got_through
+ identify
get "/"
- assert_equal "alpha@example.com", @spy.env[ProTacts::TailscaleAuth::IDENTITY]
+ assert_equal ProTacts::TailscaleAuth::Identity.new(login: "alpha@example.com", name: "Alpha Chen"), identity
end
def test_request_without_an_identity_is_refused
@@ -48,8 +57,95 @@ class TailscaleAuthTest < Minitest::Test
assert_nil @spy.env
end
- def test_request_with_an_empty_identity_is_refused
- header "Tailscale-User-Login", ""
+ def test_request_with_an_empty_login_is_refused
+ identify(login: "")
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_request_with_a_blank_login_is_refused
+ identify(login: " ")
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_request_without_a_name_is_refused
+ header "Tailscale-User-Login", "alpha@example.com"
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_request_with_a_blank_name_is_refused
+ identify(name: " ")
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ # What Go's mime.QEncoding writes for a non-ASCII value, which is how
+ # serve sends one.
+ def test_a_q_encoded_name_is_decoded
+ identify(name: "=?utf-8?q?Zo=C3=AB_Chen?=")
+
+ get "/"
+
+ assert_equal "Zoë Chen", identity.name
+ end
+
+ def test_a_q_encoded_login_is_decoded
+ identify(login: "=?utf-8?q?zo=C3=AB@example.com?=")
+
+ get "/"
+
+ assert_equal "zoë@example.com", identity.login
+ end
+
+ def test_whitespace_between_encoded_words_is_dropped
+ identify(name: "=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=")
+
+ get "/"
+
+ assert_equal "Zoë Chen", identity.name
+ end
+
+ def test_whitespace_around_plain_text_is_kept
+ identify(name: "Dr =?utf-8?q?Zo=C3=AB?= Chen")
+
+ get "/"
+
+ assert_equal "Dr Zoë Chen", identity.name
+ end
+
+ def test_a_b_encoded_name_is_decoded
+ identify(name: "=?UTF-8?B?Wm/DqyBDaGVu?=")
+
+ get "/"
+
+ assert_equal "Zoë Chen", identity.name
+ end
+
+ def test_a_name_in_another_charset_is_refused
+ identify(name: "=?iso-8859-1?q?Zo=EB?=")
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_a_name_that_decodes_to_invalid_utf8_is_refused
+ identify(name: "=?utf-8?q?Zo=EB?=")
get "/"
@@ -57,8 +153,8 @@ class TailscaleAuthTest < Minitest::Test
assert_nil @spy.env
end
- def test_request_with_a_blank_identity_is_refused
- header "Tailscale-User-Login", " "
+ def test_a_malformed_b_word_is_refused
+ identify(name: "=?utf-8?b?not*base64?=")
get "/"
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index e33aeee..2542c27 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -27,6 +27,7 @@ class WebTest < Minitest::Test
def setup
setup_sentry
header "Tailscale-User-Login", "test@example.com"
+ header "Tailscale-User-Name", "Test User"
end
def teardown