Refuse writes another site's page sends
The login is ambient -- the proxy writes it for whichever tailnet
device is asking -- so a page elsewhere could submit an admin form and
the app would apply it as that family member. The trunk now answers a
write the browser marks as cross-site (Sec-Fetch-Site, or Origin where
that is missing) with a 403 before any route runs. DAV clients send
neither header and pass.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VNdx5qiRSWADBoVfDRcrd
diff --git a/AGENTS.md b/AGENTS.md
index e657c84..90dccc8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,6 +94,10 @@ no `sync:*` group; a test that needs everyone's book makes it.
safe only because a proxy writes the header. `StubLogin` fills it in
for the fixture book in `demo.ru` and nowhere else: never in
`config.ru`, and never in front of real data.
+- That identity is ambient, so a page on another site could submit a
+ form as whoever's device opened it. `CrossSite` refuses a write the
+ browser marks as cross-site before any route runs; a form's POST
+ from this app's own pages is same-origin and passes.
- Failed DAV exchanges are written whole to `log/exchange.log` under the
id Sentry carries as the `exchange` tag. Look there first when a
client asks for something, and promote one with
diff --git a/lib/pro_tacts/cross_site.rb b/lib/pro_tacts/cross_site.rb
new file mode 100644
index 0000000..50739fc
--- /dev/null
+++ b/lib/pro_tacts/cross_site.rb
@@ -0,0 +1,51 @@
+require "uri"
+
+module ProTacts
+ # Whether a request is a write another site's page made the browser
+ # send. The identity is ambient — the proxy writes the login for
+ # whichever tailnet device is asking (ProxyAuth), so no cookie is
+ # involved and SameSite has nothing to hold back — and a page open on
+ # a family member's device can submit a form here, which the browser
+ # sends without a preflight and the app would apply as that member.
+ # DAV's PUT and DELETE already need a preflight nothing answers, so
+ # it is the admin's POSTs this refuses in practice; every method that
+ # changes something is asked, so a verb added later is covered too.
+ #
+ # The browser says where a request came from: Sec-Fetch-Site on every
+ # browser that sends it, and Origin on the older ones that do not.
+ # A request carrying neither is no browser's — a DAV client, curl —
+ # and passes, being nothing a page could have sent.
+ module CrossSite
+ # The methods that change nothing, and so have nothing to forge.
+ READS = %w[GET HEAD OPTIONS PROPFIND REPORT].freeze #: Array[String]
+
+ # Sec-Fetch-Site's answers for a request this site's own page sent,
+ # or one a person typed or bookmarked (`none`).
+ OWN = %w[same-origin none].freeze #: Array[String]
+
+ #: (Rack::env env) -> bool
+ def self.write?(env)
+ return false if READS.include?(env["REQUEST_METHOD"])
+
+ site = env["HTTP_SEC_FETCH_SITE"]
+ return !OWN.include?(site) unless site.nil?
+
+ origin = env["HTTP_ORIGIN"]
+ return false if origin.nil?
+
+ foreign?(origin, env["HTTP_HOST"].to_s)
+ end
+
+ # An Origin naming a host other than the one asked, `null` (an
+ # opaque origin, a sandboxed frame's) included, whose URI has no
+ # host at all. Both are read as URIs so the port falls away from
+ # each the same way. One that does not parse is no page of ours
+ # either.
+ #: (String origin, String host) -> bool
+ def self.foreign?(origin, host)
+ URI(origin).host != URI("//#{host}").host
+ rescue URI::InvalidURIError
+ true
+ end
+ end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 26ab8f7..cabd7cc 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,6 +8,7 @@ require "rack/rewindable_input"
require "roda"
require "pro_tacts/contact"
+require "pro_tacts/cross_site"
require "pro_tacts/exchange_log"
require "pro_tacts/refusal_alerts"
require "pro_tacts/store"
@@ -108,6 +109,15 @@ module ProTacts
# nobody and the error handler above answers it.
@login = ProxyAuth.login(r.env)
+ # A write another site's page sent, refused before any route
+ # can apply it (CrossSite).
+ if CrossSite.write?(r.env)
+ response.status = 403
+ response["Content-Type"] = "text/plain"
+ response.write("Forbidden: a write from another site's page.\n")
+ r.halt
+ end
+
r.public
r.hash_branches
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 4c1a84f..6a7f477 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -203,6 +203,70 @@ class WebTest < Minitest::Test
end
end
+ ## Refusing a write another site's page sent
+
+ # The forged create: a page elsewhere submitting the dashboard's
+ # form, which the browser marks as cross-site.
+ def test_a_cross_site_write_is_refused
+ with_contacts({}) do |store|
+ header "Sec-Fetch-Site", "cross-site"
+
+ post "/contacts", first: "Mallory"
+
+ assert_equal 403, last_response.status
+ assert_empty store.contacts
+ end
+ end
+
+ def test_a_same_origin_write_is_applied
+ with_contacts({}) do |store|
+ header "Sec-Fetch-Site", "same-origin"
+
+ post "/contacts", first: "Grace"
+
+ assert_equal 303, last_response.status
+ assert_equal ["Grace"], store.contacts.map(&:name)
+ end
+ end
+
+ # A browser too old to send Sec-Fetch-Site still sends Origin.
+ def test_a_write_from_another_origin_is_refused
+ header "Origin", "https://evil.example"
+
+ post "/contacts", first: "Mallory"
+
+ assert_equal 403, last_response.status
+ end
+
+ def test_a_write_from_this_origin_is_applied
+ with_contacts({}) do
+ header "Origin", "http://example.org"
+
+ post "/contacts", first: "Grace"
+
+ assert_equal 303, last_response.status
+ end
+ end
+
+ # A sandboxed frame's opaque origin names no host at all.
+ def test_a_write_from_an_opaque_origin_is_refused
+ header "Origin", "null"
+
+ post "/contacts", first: "Mallory"
+
+ assert_equal 403, last_response.status
+ end
+
+ # A cross-site read changes nothing, and a link followed from
+ # elsewhere is how a person reaches the dashboard.
+ def test_a_cross_site_read_is_answered
+ header "Sec-Fetch-Site", "cross-site"
+
+ get "/"
+
+ assert_equal 200, last_response.status
+ end
+
def test_get_unknown_contact_is_404
get "/dav/addressbook/nope.vcf"