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
change
commit 0b64468328b3ed0e9b43142ce97a86439b9e43ff
author Claude <noreply@anthropic.com>
date
parent lzpqyzro
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"