Authenticate on the Tailscale identity header
Serve strips Tailscale-User-Login from incoming requests before setting it
from the tailnet identity, so the header can be trusted, but only behind
serve — reached directly the app trusts whatever it is handed. Failing
closed also shuts out the two cases Tailscale documents as identity-less:
Funnel traffic and tagged devices.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index d5f1a86..a39c72a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,6 +60,10 @@ before regenerating.
- 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
+ security of that rests on the app being reachable only through
+ `tailscale serve` — never bind it to anything but localhost.
- `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
String literals are frozen; mutating one raises.
- Application code reads configuration through `ProTacts.config` only; add
diff --git a/README.md b/README.md
index 3ba57c9..b8f9812 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,14 @@ later work has a known-good baseline to change. See
established. Etags, the ctag, and the sync token are derived from file
state — a contact's etag hashes its rendered vCard, and the collection
tags hash the membership — so an edit on disk reaches synced clients on
-their next poll. There is no authentication yet.
+their next poll.
+
+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
+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.
## The minimal set macOS Contacts needs
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 08c8410..9defea3 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -16,8 +16,11 @@ module ProTacts
IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
HEX = "0123456789abcdef"
- # Username and password are a throwaway fictional pair, inlined in the
- # template. Real auth is its own backlog task.
+ # The username and password are a throwaway fictional pair and the server
+ # ignores them: identity comes from the Tailscale headers that serve
+ # injects (see ProTacts::TailscaleAuth). They stay in the template
+ # because the account form expects the fields; dropping them is
+ # untested.
def self.render(hostname:)
identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
new file mode 100644
index 0000000..603cfbe
--- /dev/null
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -0,0 +1,46 @@
+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.
+ #
+ # 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.
+ class TailscaleAuth
+ 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"
+
+ def initialize(app)
+ @app = app
+ end
+
+ 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?
+
+ env[IDENTITY] = login
+ @app.call(env)
+ end
+
+ private
+
+ def forbidden
+ body = "Forbidden: no Tailscale identity on this request.\n"
+ [403, { "Content-Type" => "text/plain", "Content-Length" => body.bytesize.to_s }, [body]]
+ end
+ end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 4437c93..ef72f21 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,6 +8,7 @@ require "roda"
require "pro_tacts/debug_logger"
require "pro_tacts/addressbook"
+require "pro_tacts/tailscale_auth"
require "roda/plugins/dav_verbs"
module ProTacts
@@ -16,6 +17,11 @@ module ProTacts
# and then rewind it so the application can still access it.
use Rack::RewindableInput::Middleware
use Sentry::Rack::CaptureExceptions
+
+ # Ahead of the debug logger on purpose: an unauthenticated request should
+ # not get its body dumped to the log.
+ use ProTacts::TailscaleAuth
+
if ProTacts.config.debug?
logger = ProTacts::DebugLogger.open_log(ProTacts.config.debug_log_path)
use ProTacts::DebugLogger, logger: logger
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index 2fdf4db..f2317ee 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -2,6 +2,8 @@
require "pathname"
require "rack/test"
+require "pro_tacts/tailscale_auth"
+
# Loads and replays the recorded macOS Contacts exchange in
# test/fixtures/macos-exchange. See that directory's README for provenance
# and the request/response file format.
@@ -34,13 +36,19 @@ module ExchangeFixtures
Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
end
+ # Stands in for the Tailscale-User-Login the recorded session 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.
+ REPLAY_LOGIN = "replay@example.com"
+
# Rack env for the recorded headers: Content-Type and Depth are the only
# ones the app reads, but replaying all of them keeps the fidelity.
def env_for(step)
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)
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
new file mode 100644
index 0000000..64b0242
--- /dev/null
+++ b/test/pro_tacts/test_tailscale_auth.rb
@@ -0,0 +1,85 @@
+require_relative "../test_helper"
+require "rack/test"
+
+require "pro_tacts/tailscale_auth"
+
+class TailscaleAuthTest < Minitest::Test
+ include Rack::Test::Methods
+
+ # Records what the middleware passed through, so a refused request can be
+ # distinguished from one the app merely ignored.
+ class Spy
+ attr_reader :env
+
+ def call(env)
+ @env = env
+ [200, { "Content-Type" => "text/plain" }, ["reached the app"]]
+ end
+ end
+
+ def setup
+ @spy = Spy.new
+ @app = ProTacts::TailscaleAuth.new(@spy)
+ end
+
+ attr_reader :app
+
+ def test_request_with_an_identity_reaches_the_app
+ header "Tailscale-User-Login", "alpha@example.com"
+
+ get "/"
+
+ assert_equal 200, last_response.status
+ assert_equal "reached the app", last_response.body
+ end
+
+ def test_identity_is_available_to_the_app
+ header "Tailscale-User-Login", "alpha@example.com"
+
+ get "/"
+
+ assert_equal "alpha@example.com", @spy.env[ProTacts::TailscaleAuth::IDENTITY]
+ end
+
+ def test_request_without_an_identity_is_refused
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_request_with_an_empty_identity_is_refused
+ header "Tailscale-User-Login", ""
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_request_with_a_blank_identity_is_refused
+ header "Tailscale-User-Login", " "
+
+ get "/"
+
+ assert_equal 403, last_response.status
+ assert_nil @spy.env
+ end
+
+ def test_refusal_explains_itself_in_plain_text
+ get "/"
+
+ assert_equal "text/plain", last_response["Content-Type"]
+ assert_includes last_response.body, "Tailscale"
+ end
+
+ # Every route is gated, not just the address book: an unauthenticated
+ # request must not learn whether a path exists.
+ def test_refusal_covers_every_path
+ %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/].each do |path|
+ get path
+
+ assert_equal 403, last_response.status, path
+ end
+ end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index c8185ce..9a9afbe 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -15,6 +15,12 @@ class WebTest < Minitest::Test
ProTacts::Web
end
+ # Every request needs a Tailscale identity; the middleware refuses without
+ # one. Tests for that refusal are in TailscaleAuthTest.
+ def setup
+ header "Tailscale-User-Login", "test@example.com"
+ end
+
def test_options_returns_dav_headers
options "/dav/"