auth: refuse a request with no identity from the route
The route refuses a request that names nobody, 401 with a Tailscale challenge;
the unhandled capture skips it, and only the debug log keeps that exchange.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index 50352d0..e35a3da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ not a fixture; edit a `.vcf` to change what the replay serves.
inside one is sent to the client — keep notes about the code in Ruby
comments outside the heredoc.
- Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
- headers or it gets a 403, so a bare `curl` against `rake dev` is
+ headers or it gets a 401, 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.
diff --git a/README.md b/README.md
index cab5438..f9081e9 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ transaction.
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
+without both gets a 401. 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 5150ff4..664535a 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -1,23 +1,23 @@
module ProTacts
- # Gates every request on the identity headers Tailscale serve injects.
+ # Who is asking, read off the identity headers Tailscale serve injects.
#
# 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
+ # the headers trustworthy, but only behind serve: reached directly, the
+ # app 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.
+ # A request that names nobody is refused at the top of Web's route
+ # (Web#unauthorized). That 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
+ # 401s.
#
# Any tailnet identity is accepted. Getting onto the tailnet is the access
# 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
-
+ module TailscaleAuth
LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
@@ -26,9 +26,14 @@ module ProTacts
# @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.
+ # 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
+
+ # The identity on a request, or nil for one that names nobody.
#: (Rack::env env) -> Identity?
def self.identity(env)
login = decode(env[LOGIN_HEADER].to_s)&.strip
@@ -38,13 +43,6 @@ module ProTacts
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
@@ -78,31 +76,5 @@ module ProTacts
nil
end
private_class_method :decode_word
-
- #: (Rack::_App app) -> void
- def initialize(app)
- @app = app
- end
-
- #: (Rack::env env) -> Rack::response
- def call(env)
- # 403 rather than 401: no credentials the client could supply would
- # help, so there is no challenge worth sending.
- return forbidden if self.class.identity(env).nil?
-
- @app.call(env)
- end
-
- private
-
- #: () -> Rack::response
- def forbidden
- body = "Forbidden: no Tailscale identity on this request.\n"
- # Lowercase names: a bare Rack 3 response hash is not normalized
- # on the way out the way Roda's response[ ]= is, and rackup's
- # development Lint rejects an uppercase name with a 500 that hides
- # the 403.
- [403, { "content-type" => "text/plain", "content-length" => body.bytesize.to_s }, [body]]
- end
end
end
diff --git a/lib/pro_tacts/unhandled_requests.rb b/lib/pro_tacts/unhandled_requests.rb
index 1dfd22b..91f0d84 100644
--- a/lib/pro_tacts/unhandled_requests.rb
+++ b/lib/pro_tacts/unhandled_requests.rb
@@ -20,10 +20,10 @@ module ProTacts
# 404 is the missing-functionality signal: a client asked for something
# this server does not route. 5xx is kept because Sentry now reports
# those without a body, and a crash is hard to read without one. 403 is
- # the routed-but-unimplemented case, today an unsupported REPORT type —
- # safe to capture because TailscaleAuth sits above this middleware and
- # refuses unauthenticated requests before they reach it, so every 403
- # arriving here came from the app.
+ # the routed-but-unimplemented case, today an unsupported REPORT type.
+ # An unauthenticated request is refused with a 401 (Web#unauthorized),
+ # which is not kept, so no capture holds a body from outside the
+ # tailnet.
#: (Integer status) -> bool
def self.capture?(status)
status == 403 || status == 404 || status >= 500
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ef506ef..684d7fb 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -29,6 +29,7 @@ module ProTacts
class Web < Roda
# @rbs @contacts: Array[Contact]?
# @rbs @ctag: String?
+ # @rbs @identity: TailscaleAuth::Identity
# The vendored Gloss CSS and the admin app's own stylesheet (see
# docs/DESIGN.md); relative to this file rather than $0 for the same
@@ -76,14 +77,16 @@ module ProTacts
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
-
- # Below the auth gate: a refused request is not missing functionality,
- # and recording one would write an unauthenticated body to disk.
+ # Outside the route's identity gate (#unauthorized), and safe there
+ # because a refusal is a 401, which it does not keep
+ # (UnhandledRequests.capture?): a refused request is not missing
+ # functionality, and recording one would write an unauthenticated
+ # body to disk.
use ProTacts::UnhandledRequests, directory: ProTacts.config.unhandled_dir
+ # Outside the identity gate too, so a refused request is dumped with
+ # the rest. That is the point of a debug log, which is off by default
+ # and kept on a local machine.
if ProTacts.config.debug?
logger = ProTacts::DebugLogger.open_log(ProTacts.config.debug_log_path)
use ProTacts::DebugLogger, logger: logger
@@ -103,6 +106,10 @@ module ProTacts
# and service discovery (RFC 6764). Each handler cites its section, and
# the texts are vendored under docs/rfcs to check them against.
route do |r|
+ # Every request names a tailnet user or is refused, the static
+ # files included (ProTacts::TailscaleAuth).
+ @identity = TailscaleAuth.identity(r.env) || unauthorized(r)
+
r.public
# The card browser, one segment down from the page at the root:
@@ -635,6 +642,19 @@ module ProTacts
)
end
+ # The refusal of a request that names nobody. A 401 must carry a
+ # challenge (RFC 9110 section 15.5.2), and `Tailscale` is no
+ # registered scheme: it tells the client that the credential is its
+ # tailnet identity, which nothing it could send supplies.
+ #: (Roda::RodaRequest r) -> bot
+ def unauthorized(r)
+ response.status = 401
+ response["WWW-Authenticate"] = "Tailscale"
+ response["Content-Type"] = "text/plain"
+ response.write("Unauthorized: no Tailscale identity on this request.\n")
+ r.halt
+ end
+
# Sync tokens are opaque to the client (RFC 6578 section 3); the URI
# form is conventional. Built on the ctag so a client polling either
# one sees changes at the same points.
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 0d28283..136ee66 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -1,6 +1,6 @@
# Roda ships no signatures. This is the routing surface web.rb uses and
# no more of it: the class methods that build the app, the request
-# methods that match, and the two response setters.
+# methods that match and halt, and the response setters and writer.
#
# Matchers stay untyped on purpose. Roda's matcher vocabulary — strings,
# classes, regexps, symbols, hashes, and arrays of those — is open, and
@@ -54,11 +54,15 @@ class Roda
# Halts the request, so nothing after it in a route block runs.
def redirect: (String path, ?Integer status) -> bot
+
+ # Ends the request with the response as it stands.
+ def halt: () -> bot
end
class RodaResponse
def []=: (String header, String value) -> void
def status=: (Integer status) -> void
+ def write: (String body) -> void
end
module RodaPlugins
diff --git a/sig/pro_tacts/tailscale_auth.rbs b/sig/pro_tacts/tailscale_auth.rbs
index 843e353..e3d6a2a 100644
--- a/sig/pro_tacts/tailscale_auth.rbs
+++ b/sig/pro_tacts/tailscale_auth.rbs
@@ -1,8 +1,8 @@
# 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 and its types are inline in tailscale_auth.rb.
module ProTacts
- class TailscaleAuth
+ module TailscaleAuth
class Identity
attr_reader login: String
attr_reader name: String
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
index ec3fcce..57a7fa6 100644
--- a/test/pro_tacts/test_tailscale_auth.rb
+++ b/test/pro_tacts/test_tailscale_auth.rb
@@ -1,181 +1,72 @@
require_relative "../test_helper"
-require "rack/test"
require "pro_tacts/tailscale_auth"
+# What the headers say. That a request naming nobody is refused, and how,
+# is Web's (see WebTest).
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 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)
+ def identity(login: "alpha@example.com", name: "Alpha Chen")
+ env = {} #: Hash[String, String]
+ env[ProTacts::TailscaleAuth::LOGIN_HEADER] = login unless login.nil?
+ env[ProTacts::TailscaleAuth::NAME_HEADER] = name unless name.nil?
+ ProTacts::TailscaleAuth.identity(env)
end
- def test_request_with_an_identity_reaches_the_app
- identify
-
- get "/"
-
- assert_equal 200, last_response.status
- assert_equal "reached the app", last_response.body
- end
-
- def test_the_app_can_read_who_got_through
- identify
-
- get "/"
-
+ def test_both_headers_are_the_identity
assert_equal ProTacts::TailscaleAuth::Identity.new(login: "alpha@example.com", name: "Alpha Chen"), identity
end
- def test_request_without_an_identity_is_refused
- get "/"
-
- assert_equal 403, last_response.status
- assert_nil @spy.env
+ def test_no_headers_are_nobody
+ assert_nil identity(login: nil, name: nil)
end
- def test_request_with_an_empty_login_is_refused
- identify(login: "")
-
- get "/"
-
- assert_equal 403, last_response.status
- assert_nil @spy.env
+ def test_an_empty_login_is_nobody
+ assert_nil identity(login: "")
end
- def test_request_with_a_blank_login_is_refused
- identify(login: " ")
-
- get "/"
-
- assert_equal 403, last_response.status
- assert_nil @spy.env
+ def test_a_blank_login_is_nobody
+ assert_nil identity(login: " ")
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
+ def test_a_missing_name_is_nobody
+ assert_nil identity(name: nil)
end
- def test_request_with_a_blank_name_is_refused
- identify(name: " ")
-
- get "/"
-
- assert_equal 403, last_response.status
- assert_nil @spy.env
+ def test_a_blank_name_is_nobody
+ assert_nil identity(name: " ")
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
+ assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB_Chen?=").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
+ assert_equal "zoë@example.com", identity(login: "=?utf-8?q?zo=C3=AB@example.com?=").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
+ assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=").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
+ assert_equal "Dr Zoë Chen", identity(name: "Dr =?utf-8?q?Zo=C3=AB?= Chen").name
end
def test_a_b_encoded_name_is_decoded
- identify(name: "=?UTF-8?B?Wm/DqyBDaGVu?=")
-
- get "/"
-
- assert_equal "Zoë Chen", identity.name
+ assert_equal "Zoë Chen", identity(name: "=?UTF-8?B?Wm/DqyBDaGVu?=").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
+ def test_a_name_in_another_charset_is_nobody
+ assert_nil identity(name: "=?iso-8859-1?q?Zo=EB?=")
end
- def test_a_name_that_decodes_to_invalid_utf8_is_refused
- identify(name: "=?utf-8?q?Zo=EB?=")
-
- get "/"
-
- assert_equal 403, last_response.status
- assert_nil @spy.env
+ def test_a_name_that_decodes_to_invalid_utf8_is_nobody
+ assert_nil identity(name: "=?utf-8?q?Zo=EB?=")
end
- def test_a_malformed_b_word_is_refused
- identify(name: "=?utf-8?b?not*base64?=")
-
- 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
+ def test_a_malformed_b_word_is_nobody
+ assert_nil identity(name: "=?utf-8?b?not*base64?=")
end
end
diff --git a/test/pro_tacts/test_unhandled_requests.rb b/test/pro_tacts/test_unhandled_requests.rb
index 03175ea..687f4ef 100644
--- a/test/pro_tacts/test_unhandled_requests.rb
+++ b/test/pro_tacts/test_unhandled_requests.rb
@@ -61,9 +61,8 @@ class UnhandledRequestsTest < Minitest::Test
assert_equal 1, captures.size
end
- # An unsupported REPORT type. TailscaleAuth refuses unauthenticated
- # requests above this middleware, so a 403 reaching it is always the app
- # saying it routed the request and will not serve it.
+ # An unsupported REPORT type: the app saying it routed the request and
+ # will not serve it. An unauthenticated request is a 401 instead.
def test_a_403_from_the_app_is_captured
@stub.status = 403
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 2542c27..7605e4b 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -21,8 +21,8 @@ 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. The sentry setup
+ # Every request needs a Tailscale identity; the route refuses without
+ # one (see the refusal tests below). The sentry setup
# pins the transport the app's reports land in; teardown clears it.
def setup
setup_sentry
@@ -121,8 +121,8 @@ class WebTest < Minitest::Test
assert_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
end
- # Guards the wiring rather than the middleware: mounted in the stack, below
- # the auth gate, pointed at the configured directory.
+ # Guards the wiring rather than the middleware: mounted in the stack and
+ # pointed at the configured directory.
def test_an_unhandled_request_is_kept_on_disk
directory = ProTacts.config.unhandled_dir
FileUtils.rm_rf(directory)
@@ -146,12 +146,45 @@ class WebTest < Minitest::Test
header "Tailscale-User-Login", ""
get "/dav/addressbook/no-such-contact.vcf"
- assert_equal 403, last_response.status
+ assert_equal 401, last_response.status
refute Pathname.new(directory).exist?, "a refused request should leave nothing behind"
ensure
FileUtils.rm_rf(directory)
end
+ ## Refusing a request that names nobody
+
+ def test_a_request_without_a_name_is_refused
+ header "Tailscale-User-Name", ""
+
+ get "/dav/principal/"
+
+ assert_equal 401, last_response.status
+ end
+
+ # RFC 9110 section 15.5.2: a 401 carries a challenge.
+ def test_a_refusal_names_tailscale_as_the_credential
+ header "Tailscale-User-Login", ""
+
+ get "/"
+
+ assert_equal "Tailscale", last_response["WWW-Authenticate"]
+ 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_a_refusal_covers_every_path
+ header "Tailscale-User-Login", ""
+
+ %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
+ get path
+
+ assert_equal 401, last_response.status, path
+ end
+ end
+
def test_get_unknown_contact_is_404
get "/dav/addressbook/nope.vcf"