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
change nuvlszpmrysuznyplolxwtuunlyvqnvt
commit 45db1f5cd72a3dfc1b4eb77cf811ce7d7456bddb
author Alpha Chen <alpha@kejadlen.dev>
date
parent nqsnzpou
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