admin: send /admin/ to /admin, also after login
All checks were successful
Build and Deploy to Production / test (push) Successful in 26s
Build and Deploy to Production / build (push) Successful in 1m1s
Build and Deploy to Production / deploy (push) Successful in 36s

Opening /admin/ logged out stored next=/admin/ in the login form, and a
successful login then redirected to /admin/, which has no route (404).

Redirect trailing-slash admin URLs to the slashless route with a 301,
and drop trailing slashes from the post-login target too.

Assisted-by: Claude:opus-5-5
This commit is contained in:
Sergei Poljanski 2026-10-18 10:39:10 +04:00
commit 9a802106ce
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
2 changed files with 19 additions and 1 deletions

View file

@ -58,8 +58,10 @@ module AdminAuth
Audit.log(action: action, user: user, target: target, detail: detail, ip: client_ip) Audit.log(action: action, user: user, target: target, detail: detail, ip: client_ip)
end end
# Trailing slashes dropped: /admin/ has no route (see the before filter).
def safe_next(target) def safe_next(target)
target.to_s.match?(%r{\A/admin(/[\w\-/]*)?\z}) ? target : '/admin' target = target.to_s.sub(%r{(?<=.)/+\z}, '')
target.match?(%r{\A/admin(/[\w\-/]*)?\z}) ? target : '/admin'
end end
end end
@ -68,6 +70,8 @@ module AdminAuth
app.before do app.before do
next unless admin_path? next unless admin_path?
# One URL per page: /admin/ and /admin/bingo/ go to the slashless route.
redirect request.fullpath.sub(%r{/+(?=\?|\z)}, ''), 301 if request.path_info.match?(%r{./\z})
halt 503, 'Admin needs DATABASE_URL.' unless defined?(User) halt 503, 'Admin needs DATABASE_URL.' unless defined?(User)
next if request.path_info.match?(PUBLIC) next if request.path_info.match?(PUBLIC)
unless current_user unless current_user

View file

@ -48,6 +48,20 @@ class AdminAuthTest < Minitest::Test
assert_equal 302, last_response.status assert_equal 302, last_response.status
end end
def test_trailing_slash_admin_urls
get '/admin/'
assert_equal 301, last_response.status
assert_match %r{/admin\z}, last_response.location
get '/admin/bingo/?x=1'
assert_match %r{/admin/bingo\?x=1\z}, last_response.location
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
get '/admin/login?next=/admin/'
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
post '/admin/login', { authenticity_token: token, username: 'alice', password: 'alice-password-1', next: '/admin/' }
assert_match %r{/admin\z}, last_response.location
end
def test_wrong_password_and_unknown_user def test_wrong_password_and_unknown_user
User.create(username: 'alice', superuser: true, password: 'alice-password-1') User.create(username: 'alice', superuser: true, password: 'alice-password-1')
login('alice', 'nope') login('alice', 'nope')