Add CSRF protection for POST requests
Session secret from SESSION_SECRET env var (random default for dev).
Roda route_csrf plugin checks POST requests only — PATCH is safe
from cross-origin forms since browsers only submit GET/POST.

CSRF tokens are request-specific, embedded as hidden fields in
each form. Views accept a csrf callable to generate tokens per
path.

Assisted-by: Claude Opus 4.6 via pi
change szpwrrrluvwsunsrpkyqxmmkkornzmvm
commit 2a340e36a4839ac54dd3456a483bbba0dddc2fed
author Alpha Chen <alpha@kejadlen.dev>
date
parent wrvltoqw
diff --git a/Steepfile b/Steepfile
index 0578d34..cd80f81 100644
--- a/Steepfile
+++ b/Steepfile
@@ -1,4 +1,5 @@
 target :lib do
   signature "sig"
+  library "securerandom"
   check "lib/ketchup/config.rb"
 end
diff --git a/lib/ketchup/config.rb b/lib/ketchup/config.rb
index fd6c128..28f964d 100644
--- a/lib/ketchup/config.rb
+++ b/lib/ketchup/config.rb
@@ -1,8 +1,11 @@
 # rbs_inline: enabled
 # frozen_string_literal: true
 
+require "securerandom"
+
 Config = Data.define(
   :database_url, #: String
+  :session_secret, #: String
   :sentry, #: SentryConfig?
   :default_user, #: DefaultUser?
   :commit_sha, #: String?
@@ -47,6 +50,7 @@ class Config
     default_user = env["DEFAULT_USER"]
     new(
       database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
+      session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
       sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
       default_user: default_user ? DefaultUser.parse(default_user) : nil,
       commit_sha: env["COMMIT_SHA"],
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 74e80f1..b4a1d43 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -12,8 +12,9 @@ module Views
   INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
 
   class Dashboard < Phlex::HTML
-    def initialize(current_user:, series: nil)
+    def initialize(current_user:, csrf:, series: nil)
       @current_user = current_user
+      @csrf = csrf
       @series = series
     end
 
@@ -22,17 +23,19 @@ module Views
         div(class: @series ? "home home--series" : "home") do
           render OverdueColumn.new(
             tasks: @current_user.overdue_tasks.all.sort_by { |t| -t.urgency },
-            selected_series: @series
+            selected_series: @series,
+            csrf: @csrf
           )
           render UpcomingColumn.new(
             tasks: @current_user.upcoming_tasks.all,
-            selected_series: @series
+            selected_series: @series,
+            csrf: @csrf
           )
 
           if @series
             render SeriesDetail.new(series: @series)
           else
-            render NewSeriesForm.new
+            render NewSeriesForm.new(csrf: @csrf)
           end
         end
       end
diff --git a/lib/ketchup/views/new_series_form.rb b/lib/ketchup/views/new_series_form.rb
index 6e95a14..12574c7 100644
--- a/lib/ketchup/views/new_series_form.rb
+++ b/lib/ketchup/views/new_series_form.rb
@@ -4,6 +4,10 @@ require "phlex"
 
 module Views
   class NewSeriesForm < Phlex::HTML
+    def initialize(csrf:)
+      @csrf = csrf
+    end
+
     def view_template
       div(class: "column column-aside column-aside--new") do
         div(class: "column-header") do
@@ -12,6 +16,7 @@ module Views
         end
 
         form(method: "post", action: "/series", id: "new-series-form", class: "task-detail") do
+          input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
           div(id: "series-note-editor", class: "task-detail-note")
 
           dl(class: "task-detail-fields") do
diff --git a/lib/ketchup/views/overdue_column.rb b/lib/ketchup/views/overdue_column.rb
index 0909550..6d457e5 100644
--- a/lib/ketchup/views/overdue_column.rb
+++ b/lib/ketchup/views/overdue_column.rb
@@ -6,8 +6,9 @@ require_relative "task_card"
 
 module Views
   class OverdueColumn < Phlex::HTML
-    def initialize(tasks:, selected_series: nil)
+    def initialize(tasks:, csrf:, selected_series: nil)
       @tasks = tasks
+      @csrf = csrf
       @selected_series = selected_series
     end
 
@@ -39,7 +40,7 @@ module Views
                 "data-urgency": format("%.4f", task.urgency),
                 "data-due-date": task[:due_date].to_s
               ) do
-                render TaskCard.new(task: task, selected: selected?(task), sortable: true)
+                render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), sortable: true)
               end
             end
           end
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 87b103a..2ed3546 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -7,8 +7,9 @@ require_relative "../layout"
 module Views
   module Series
     class New < Phlex::HTML
-      def initialize(current_user:)
+      def initialize(current_user:, csrf:)
         @current_user = current_user
+        @csrf = csrf
       end
 
       def view_template
@@ -17,6 +18,7 @@ module Views
             h1 { "New Series" }
 
             form(method: "post", action: "/series") do
+              input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
               div(class: "field") do
                 label(for: "note") { "Note" }
                 textarea(id: "note", name: "note", rows: 3, required: true)
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index f8f38b6..0074cae 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -4,8 +4,9 @@ require "phlex"
 
 module Views
   class TaskCard < Phlex::HTML
-    def initialize(task:, selected: false, sortable: false)
+    def initialize(task:, csrf:, selected: false, sortable: false)
       @task = task
+      @csrf = csrf
       @selected = selected
       @sortable = sortable
     end
@@ -13,9 +14,11 @@ module Views
     def view_template
       name = @task[:note].lines.first&.strip || @task[:note]
       overdue = @task[:due_date] < Date.today
+      complete_path = "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete"
 
       div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if @selected)]) do
-        form(method: "post", action: "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete", class: "complete-form") do
+        form(method: "post", action: complete_path, class: "complete-form") do
+          input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
           button(
             type: "submit", title: "Complete",
             class: "complete-btn",
diff --git a/lib/ketchup/views/upcoming_column.rb b/lib/ketchup/views/upcoming_column.rb
index 9ee4790..48caba7 100644
--- a/lib/ketchup/views/upcoming_column.rb
+++ b/lib/ketchup/views/upcoming_column.rb
@@ -6,8 +6,9 @@ require_relative "task_card"
 
 module Views
   class UpcomingColumn < Phlex::HTML
-    def initialize(tasks:, selected_series: nil)
+    def initialize(tasks:, csrf:, selected_series: nil)
       @tasks = tasks
+      @csrf = csrf
       @selected_series = selected_series
     end
 
@@ -68,7 +69,7 @@ module Views
           next if empty
           day_tasks.each do |task|
             li(class: ["task-item", ("calendar-day-weekend" if weekend)]) do
-              render TaskCard.new(task: task, selected: selected?(task))
+              render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task))
             end
           end
         end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index a09063d..3643290 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -11,6 +11,8 @@ class Web < Roda
   plugin :halt
   plugin :static, %w[ /css /js ]
   plugin :all_verbs
+  plugin :sessions, secret: CONFIG.session_secret
+  plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
   plugin :error_handler do |e|
     raise e unless e.is_a?(Sequel::NoMatchingRow)
 
@@ -30,13 +32,15 @@ class Web < Roda
     @user = current_user
     r.halt 403 unless @user
 
+    check_csrf!
+
     r.root do
-      Views::Dashboard.new(current_user: @user).call
+      Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
     end
 
     r.on "series" do
       r.get "new" do
-        Views::Series::New.new(current_user: @user).call
+        Views::Series::New.new(current_user: @user, csrf: method(:csrf_token)).call
       end
 
       r.is do
@@ -73,7 +77,7 @@ class Web < Roda
 
         r.is do
           r.get do
-            Views::Dashboard.new(current_user: @user, series: @series).call
+            Views::Dashboard.new(current_user: @user, series: @series, csrf: method(:csrf_token)).call
           end
 
           r.patch do
diff --git a/test/test_web.rb b/test/test_web.rb
index 0ae58ee..f54e4c5 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -40,15 +40,17 @@ class TestWeb < Minitest::Test
   end
 
   def test_root_only_shows_own_tasks
-    post "/series", {
+    create_series(
       note: "Alice task", interval_unit: "week", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+    )
 
-    post "/series", {
+    create_series(
       note: "Bob task", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "bob@example.com", name: "Bob")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "bob@example.com", name: "Bob")
+    )
 
     get "/", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
     assert_includes last_response.body, "Alice task"
@@ -72,10 +74,10 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series
-    post "/series", {
+    create_series(
       note: "Call Mom", interval_unit: "week", interval_count: "2",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
     assert last_response.redirect?
 
     series = DB[:series].first
@@ -86,10 +88,10 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series_creates_first_task
-    post "/series", {
+    create_series(
       note: "Call Mom", interval_unit: "week", interval_count: "2",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
 
     series = DB[:series].first
     task = DB[:tasks].first(series_id: series[:id])
@@ -98,10 +100,11 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series_belongs_to_current_user
-    post "/series", {
+    create_series(
       note: "Dentist", interval_unit: "quarter", interval_count: "1",
-      first_due_date: "2026-06-01"
-    }, tailscale_headers(login: "dave@example.com", name: "Dave")
+      first_due_date: "2026-06-01",
+      headers: tailscale_headers(login: "dave@example.com", name: "Dave")
+    )
 
     series = DB[:series].first
     user = DB[:users].first(login: "dave@example.com")
@@ -109,45 +112,45 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series_strips_whitespace
-    post "/series", {
+    create_series(
       note: "  Trim me  ", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
     assert_equal "Trim me", DB[:series].first[:note]
   end
 
   def test_create_series_rejects_empty_note
-    post "/series", {
+    create_series(
       note: "  ", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
   def test_create_series_rejects_invalid_interval_unit
-    post "/series", {
+    create_series(
       note: "Nope", interval_unit: "fortnight", interval_count: "1",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
   def test_create_series_rejects_zero_interval_count
-    post "/series", {
+    create_series(
       note: "Nope", interval_unit: "day", interval_count: "0",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    )
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
   def test_create_series_rejects_invalid_due_date
-    post "/series", {
+    create_series(
       note: "Nope", interval_unit: "day", interval_count: "1",
       first_due_date: "not-a-date"
-    }, tailscale_headers
+    )
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
     assert_equal 0, DB[:tasks].count
@@ -158,10 +161,11 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    series = DB[:series].first
+    complete_path = "/series/#{series[:id]}/tasks/#{task[:id]}/complete"
+    csrf_post complete_path, {}, tailscale_headers
     assert last_response.redirect?
 
-    series = DB[:series].first
     assert_includes last_response["Location"], "/series/#{series[:id]}"
 
     old_task = DB[:tasks].first(id: task[:id])
@@ -176,7 +180,8 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-01-31")
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
 
     new_task = DB[:tasks].where(completed_at: nil).first
     assert_equal Date.today >> 3, new_task[:due_date]
@@ -194,14 +199,15 @@ class TestWeb < Minitest::Test
   end
 
   def test_complete_task_requires_own_task
-    post "/series", {
+    create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+    )
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
-    assert_equal 404, last_response.status
+    csrf_post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+    assert_includes [403, 404], last_response.status
   end
 
   def test_complete_task_requires_tailscale_user
@@ -241,6 +247,13 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, "action=\"/series/#{series[:id]}/tasks/#{task[:id]}/complete\""
   end
 
+  def test_task_card_has_csrf_token
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: "2026-03-01")
+
+    get "/", {}, tailscale_headers
+    assert_includes last_response.body, 'name="_csrf"'
+  end
 
   def test_series_sidebar_has_new_link
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
@@ -269,10 +282,10 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
 
     completed_task = DB[:tasks].first(id: task[:id])
-    series = DB[:series].first
     patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
     get "/series/#{series[:id]}", {}, tailscale_headers
     assert last_response.ok?
@@ -281,10 +294,11 @@ class TestWeb < Minitest::Test
   end
 
   def test_get_series_requires_own_series
-    post "/series", {
+    create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+    )
 
     series = DB[:series].first
     get "/series/#{series[:id]}", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
@@ -301,10 +315,10 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
 
     completed_task = DB[:tasks].first(id: task[:id])
-    series = DB[:series].first
     patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, tailscale_headers
     assert last_response.ok?
 
@@ -324,16 +338,17 @@ class TestWeb < Minitest::Test
   end
 
   def test_patch_note_requires_own_task
-    post "/series", {
+    create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+    )
 
     task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
 
     completed_task = DB[:tasks].first(id: task[:id])
-    series = DB[:series].first
     patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
     assert_equal 404, last_response.status
   end
@@ -401,10 +416,11 @@ class TestWeb < Minitest::Test
   end
 
   def test_patch_series_requires_own_series
-    post "/series", {
+    create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
-    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+      first_due_date: "2026-03-01",
+      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+    )
 
     series = DB[:series].first
     patch "/series/#{series[:id]}", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
@@ -428,13 +444,33 @@ class TestWeb < Minitest::Test
     assert_equal Date.new(2026, 3, 1), task[:due_date]
   end
 
+  def test_csrf_rejects_post_without_token
+    get "/", {}, tailscale_headers  # establish session
+    post "/series", {
+      note: "No token", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+    assert_equal 403, last_response.status
+  end
+
   private
 
-  def create_series(note:, interval_unit:, interval_count:, first_due_date:)
+  def csrf_post(path, params = {}, headers = tailscale_headers)
+    get "/", {}, headers  # establish session and get tokens
+    token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
+    post path, params.merge("_csrf" => token), headers
+  end
+
+
+
+  def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: tailscale_headers)
+    get "/", {}, headers  # establish session and get tokens
+    token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/series", {
+      _csrf: token,
       note: note, interval_unit: interval_unit, interval_count: interval_count,
       first_due_date: first_due_date
-    }, tailscale_headers
+    }, headers
   end
 
   def tailscale_headers(