Skip to content

Commit cd32e68

Browse files
committed
fix: keep the CSRF secret for the session
The dead-letter list renders one Retry form per row, and a browser keeps pages open in other tabs. Rotating the session secret on the first submission answered 403 to every other form the same page had already rendered, so retrying a second dead letter appeared to be forbidden until the operator reloaded. Single use is not what a CSRF token provides. It proves the request came from a page this session was served; the per-request mask is what keeps the value on the wire from repeating, which is the property rotation looked like it was adding. The mount check now forges a token of the right length so it reaches the comparison rather than being turned away by the length check, which is what a wrong-but-well-formed token would do.
1 parent 1f632c7 commit cd32e68

4 files changed

Lines changed: 61 additions & 4 deletions

File tree

lib/solid_objects/web/csrf_protection.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,13 @@ def valid?(env, given)
6464
token = decode(given)
6565
return false unless token
6666

67-
# The session token is replaced whether or not this comparison
68-
# succeeds, so a token cannot be replayed after it is spent.
69-
session[:csrf] = SecureRandom.base64(TOKEN_BYTES)
67+
# The secret is not rotated here. A page renders one Retry form per
68+
# dead letter, and a browser keeps pages open in other tabs, so
69+
# spending the secret on the first submission would answer 403 to
70+
# every other form already rendered. Single use is not what a CSRF
71+
# token provides: it proves the request came from a page this session
72+
# was served, and the per-request mask below is what keeps the value
73+
# on the wire from repeating.
7074
matches?(token, stored)
7175
end
7276

test/dummy/web_mount_check.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ def pause(request, path, cookie, token)
5050
request.post("#{path}/pause", options)
5151
end
5252

53-
forged = pause(request, detail_path, cookie, "forged")
53+
# Well formed and the right length, so it reaches the comparison rather than
54+
# being turned away by the length check on the way in.
55+
forged = pause(request, detail_path, cookie, SecureRandom.base64(32))
5456
forged_paused = instance.reload.paused_at
5557

5658
paused = pause(request, detail_path, cookie, token)

test/integration/web_test.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,45 @@ class WebTest < WebTestCase
157157
assert_nil instance.reload.paused_at
158158
end
159159

160+
test "rejects a token that was not derived from this session" do
161+
instance = create_instance
162+
get("/")
163+
164+
response = request(
165+
"/instances/#{instance.id}/pause",
166+
method: "POST",
167+
params: { "authenticity_token" => SecureRandom.base64(32) }
168+
)
169+
170+
assert_equal 403, response.status
171+
assert_nil instance.reload.paused_at
172+
end
173+
174+
# The dead-letter list renders one Retry form per row, and a browser keeps
175+
# pages open in other tabs. Spending the session secret on the first
176+
# submission would answer 403 to every other form the same page rendered.
177+
test "accepts every form a page rendered, not only the first" do
178+
instance = create_instance
179+
token = rendered_token("/instances/#{instance.id}")
180+
181+
paused = submit("/instances/#{instance.id}/pause", token)
182+
resumed = submit("/instances/#{instance.id}/resume", token)
183+
184+
assert_equal 302, paused.status
185+
assert_equal 302, resumed.status, "a second form from the same page must still be accepted"
186+
assert_nil instance.reload.paused_at
187+
end
188+
189+
test "issues a differently masked token on each request" do
190+
instance = create_instance
191+
first = rendered_token("/instances/#{instance.id}")
192+
second = rendered_token("/instances/#{instance.id}")
193+
194+
refute_equal first, second, "an unchanging token on the wire is what masking prevents"
195+
assert_equal 302, submit("/instances/#{instance.id}/pause", first).status
196+
assert_equal 302, submit("/instances/#{instance.id}/resume", second).status
197+
end
198+
160199
test "lists ready and claimed mailbox messages" do
161200
instance = create_instance
162201
mark_ready(create_message(instance, operation: "increment"))

test/web_test_helper.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ def post_without_csrf_token(path, params = {})
4545
request(path, method: "POST", params:)
4646
end
4747

48+
# The masked token a page actually put in its forms, rather than the session
49+
# value behind it.
50+
# @rbs (String) -> String
51+
def rendered_token(path)
52+
get(path).body[/name="authenticity_token" value="([^"]+)"/, 1]
53+
end
54+
55+
# @rbs (String, String, ?Hash[String, untyped]) -> Rack::MockResponse
56+
def submit(path, token, params = {})
57+
request(path, method: "POST", params: params.merge("authenticity_token" => token))
58+
end
59+
4860
# @rbs (String, method: String, params: Hash[String, untyped]) -> Rack::MockResponse
4961
def request(path, method:, params:)
5062
environment = Rack::MockRequest.env_for(

0 commit comments

Comments
 (0)