auth: read the login off Remote-User alone
The header was a setting so tailscale serve could name its own;
Caddy writes Remote-User now, so the knob was a 401 left on loan.
The refusal names the one header, and env_key is gone,
since a single fixed name leaves nothing to spell out from one.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/AGENTS.md b/AGENTS.md
index 169d735..6abba31 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -96,12 +96,10 @@ not a fixture; edit a `.vcf` to change what the replay serves.
`DavXml`, whose methods are the only elements the server can send. Inside
an element's block, `it` is the builder, not an enclosing block's value,
so name the outer block's parameter.
-- Every request needs the identity header or it gets a 401, so a bare
- `curl` against `rake dev` is refused until you pass `Remote-User`
- (`PRO_TACTS_IDENTITY_HEADER` renames it, and the refusal says which
- name it read). The security of that rests on the app being reachable
- only through a proxy that writes the header itself — never bind it to
- anything but localhost.
+- Every request needs a `Remote-User` header or it gets a 401, so a bare
+ `curl` against `rake dev` is refused until you pass it. The security of
+ that rests on the app being reachable only through a proxy that writes
+ the header itself — never bind it to anything but localhost.
- DAV exchanges that went wrong (a status of 400 or more but 401, a
crash, or a report to Sentry) are written whole to `log/exchange.log`,
each line prefixed by the id Sentry carries as the `exchange` tag.
diff --git a/README.md b/README.md
index 27bcc82..5ac9621 100644
--- a/README.md
+++ b/README.md
@@ -58,11 +58,9 @@ change log the sync tokens count on is written with it, in one
transaction.
Requests are authenticated by one request header carrying the
-requester's login: `Remote-User`, unless `PRO_TACTS_IDENTITY_HEADER`
-names another. The proxy in front of the app writes that header.
-`tailscale serve` writes `Tailscale-User-Login` and strips it from
-incoming requests, and a Caddy site's `header_up` overwrites whatever
-arrived, so a client cannot forge either one. A request without a login
+requester's login: `Remote-User`. The proxy in front of the app writes
+that header. A Caddy site's `header_up` overwrites whatever arrived, so
+a client cannot forge it. A request without a login
gets a 401. That holds only while the app is reachable through such a
proxy alone — bind it to localhost.
Tailscale documents two cases that carry no identity and so cannot get in:
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 9ed510a..e37e755 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -61,17 +61,6 @@ module ProTacts
Pathname.new(@env.fetch("PRO_TACTS_EXCHANGE_LOG", data_dir / "log/exchange.log"))
end
- # The request header a requester's login is read off,
- # PRO_TACTS_IDENTITY_HEADER — the whole of the identity this app has
- # (see ProTacts::ProxyAuth). A deployment names the header its own
- # proxy writes: `tailscale serve` writes Tailscale-User-Login, and a
- # Caddy site writes whatever its header_up names. The default is what
- # a reverse proxy conventionally writes the authenticated user to.
- #: () -> String
- def identity_header
- @env.fetch("PRO_TACTS_IDENTITY_HEADER", "Remote-User")
- end
-
# The CardDAV account's display name, PRO_TACTS_PROFILE_NAME — what
# a device labels the account it installs, and the one way to tell
# two pro-tacts accounts apart on the same phone. nil when unset
diff --git a/lib/pro_tacts/proxy_auth.rb b/lib/pro_tacts/proxy_auth.rb
index db23f22..4523650 100644
--- a/lib/pro_tacts/proxy_auth.rb
+++ b/lib/pro_tacts/proxy_auth.rb
@@ -1,17 +1,14 @@
require "pro_tacts"
module ProTacts
- # Who is asking: the login on the one request header the proxy in front
- # of this app writes it to, named by ProTacts.config.identity_header
- # (docs/plans/2026-09-15-identity-from-one-header.md).
+ # Who is asking: the login on Remote-User, which the proxy in front of
+ # this app writes (docs/plans/2026-09-15-identity-from-one-header.md).
#
- # `tailscale serve` writes Tailscale-User-Login from the tailnet
- # identity of the calling node and strips it from incoming requests
- # before proxying, and a Caddy site's `header_up` sets the header it
- # names, overwriting whatever arrived. Either way a client cannot
- # supply its own. That makes the header trustworthy, but only behind
- # such a proxy: reached directly, the app trusts whatever it is
- # handed. The app must not be listening anywhere but localhost.
+ # The Caddy site's `header_up` sets Remote-User, overwriting whatever
+ # arrived, so a client cannot supply its own. That makes the header
+ # trustworthy, but only behind such a proxy: reached directly, the app
+ # trusts whatever it is handed. The app must not be listening anywhere
+ # but localhost.
#
# A request that names nobody is refused at the top of Web's route
# (Web#unauthorized). That covers the two cases Tailscale documents as
@@ -30,20 +27,14 @@ module ProTacts
ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
private_constant :ENCODED_WORD, :ENCODED_RUN
- # The login on a request, or nil for one that names nobody. The
- # header is an argument so a caller can read one this deployment is
- # not configured for; every caller in the app takes the default.
- #: (Rack::env env, ?header: String) -> String?
- def self.login(env, header: ProTacts.config.identity_header)
- login = decode(env[env_key(header)].to_s)&.strip
- login unless login.nil? || login.empty?
- end
+ # Remote-User as Rack spells it in the environment.
+ ENV_KEY = "HTTP_REMOTE_USER" #: String
- # Rack's spelling of a request header (the Rack SPEC's environment):
- # upcased, dashes to underscores, HTTP_ in front.
- #: (String header) -> String
- def self.env_key(header)
- "HTTP_#{header.upcase.tr("-", "_")}"
+ # The login on a request, or nil for one that names nobody.
+ #: (Rack::env env) -> String?
+ def self.login(env)
+ login = decode(env[ENV_KEY].to_s)&.strip
+ login unless login.nil? || login.empty?
end
# A header value as serve writes it: Go's mime.QEncoding over the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fce7edb..d7b7a06 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -109,21 +109,18 @@ module ProTacts
# login its proxy vouches for, which nothing the client could send
# supplies.
#
- # The body names the header that was read and the setting that chose
- # it, because the failure it reports is almost always a deployment
- # reading one header while its proxy writes another, and the name is
- # the whole of what tells them apart. Naming it hands an attacker
- # nothing: the proxy overwrites the header on every request, so
- # knowing which one it is buys no way to forge it.
+ # The body names the header that was read, because the failure it
+ # reports is almost always a proxy writing some other one. Naming it
+ # hands an attacker nothing: the proxy overwrites the header on every
+ # request, so knowing which one it is buys no way to forge it.
#: (Roda::RodaRequest r) -> bot
def unauthorized(r)
response.status = 401
response["WWW-Authenticate"] = "Proxy-Identity"
response["Content-Type"] = "text/plain"
response.write(
- "Unauthorized: no login on the #{ProTacts.config.identity_header} header.\n" \
- "The proxy in front of this app writes that header; " \
- "PRO_TACTS_IDENTITY_HEADER names which one is read.\n"
+ "Unauthorized: no login on the Remote-User header.\n" \
+ "The proxy in front of this app writes that header.\n"
)
r.halt
end
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index e7cc78d..cec37a4 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -76,7 +76,7 @@ class ExchangeFixtures < Data.define(:directory)
key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
[key, value]
}.merge(
- ProTacts::ProxyAuth.env_key(ProTacts.config.identity_header) => REPLAY_LOGIN,
+ ProTacts::ProxyAuth::ENV_KEY => REPLAY_LOGIN,
)
end
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 1974ea5..8dc0a5a 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -58,16 +58,6 @@ class ConfigTest < Minitest::Test
assert_equal Pathname.new("stderr"), ProTacts::Config.new("PRO_TACTS_EXCHANGE_LOG" => "stderr").exchange_log_path
end
- def test_identity_header_defaults_to_remote_user
- assert_equal "Remote-User", ProTacts::Config.new({}).identity_header
- end
-
- def test_identity_header_is_overridable
- config = ProTacts::Config.new("PRO_TACTS_IDENTITY_HEADER" => "Tailscale-User-Name")
-
- assert_equal "Tailscale-User-Name", config.identity_header
- end
-
# nil rather than a default, so the fallback stays Profile's alone.
def test_profile_name_is_nil_when_unset
assert_nil ProTacts::Config.new({}).profile_name
diff --git a/test/pro_tacts/test_proxy_auth.rb b/test/pro_tacts/test_proxy_auth.rb
index 459116e..b8efa06 100644
--- a/test/pro_tacts/test_proxy_auth.rb
+++ b/test/pro_tacts/test_proxy_auth.rb
@@ -5,14 +5,10 @@ require "pro_tacts/proxy_auth"
# What the header says. That a request naming nobody is refused, and how,
# is Web's (see WebTest).
class ProxyAuthTest < Minitest::Test
- # The header these tests configure the read for, passed explicitly so
- # nothing here depends on what ProTacts.config defaults to.
- HEADER = "Remote-User"
-
- def login(value = "alpha@example.com", sent_as: HEADER)
+ def login(value = "alpha@example.com", sent_as: "HTTP_REMOTE_USER")
env = {} #: Hash[String, String]
- env[ProTacts::ProxyAuth.env_key(sent_as)] = value unless value.nil?
- ProTacts::ProxyAuth.login(env, header: HEADER)
+ env[sent_as] = value unless value.nil?
+ ProTacts::ProxyAuth.login(env)
end
def test_the_header_is_the_login
@@ -32,18 +28,7 @@ class ProxyAuthTest < Minitest::Test
end
def test_a_login_on_another_header_is_nobody
- assert_nil login(sent_as: "Tailscale-User-Login")
- end
-
- def test_the_header_read_is_the_one_named
- env = { ProTacts::ProxyAuth.env_key("Tailscale-User-Login") => "alpha@example.com" }
-
- assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-User-Login")
- end
-
- def test_a_header_name_is_upcased_and_prefixed
- assert_equal "HTTP_REMOTE_USER", ProTacts::ProxyAuth.env_key("Remote-User")
- assert_equal "HTTP_TAILSCALE_USER_LOGIN", ProTacts::ProxyAuth.env_key("tailscale-user-login")
+ assert_nil login(sent_as: "HTTP_TAILSCALE_USER_LOGIN")
end
# What Go's mime.QEncoding writes for a non-ASCII value, which is how
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 880b057..f08e3a8 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -22,8 +22,7 @@ class WebTest < Minitest::Test
end
# Every request names a tailnet user; the route refuses without one
- # (see the refusal tests below). The header is the configured default
- # (ProTacts::Config#identity_header). The sentry setup
+ # (see the refusal tests below). The sentry setup
# pins the transport the app's reports land in; teardown clears it.
def setup
setup_sentry
@@ -183,15 +182,13 @@ class WebTest < Minitest::Test
assert_equal "text/plain", last_response["Content-Type"]
end
- # The header read and the setting that chose it, which is what tells a
- # deployment reading one header from a proxy writing another apart.
+ # The header read, which is what tells a proxy writing another apart.
def test_a_refusal_names_the_header_it_read
header "Remote-User", ""
get "/"
assert_includes last_response.body, "Remote-User"
- assert_includes last_response.body, "PRO_TACTS_IDENTITY_HEADER"
end
# Every route is gated, not just the address book: an unauthenticated