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
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(