Block SSRF via backchannel_logout_uri
backchannel_logout_uri was validated only for scheme/HTTPS, so an admin (or a compromised admin account) could point it at internal infrastructure — cloud metadata (169.254.169.254), loopback, or RFC1918 hosts — and every user logout would fire a server-side POST there. Add PrivateAddressCheck (app/lib) and apply it as defense-in-depth: - Application validation rejects URIs whose host is, or is a literal, internal address (loopback / private / link-local / 0.0.0.0 / localhost / metadata hostnames). Fast, DNS-free, immediate admin feedback. - BackchannelLogoutJob re-checks at request time WITH DNS resolution and aborts (no retry) if the host resolves to a non-public address — covering URIs that predate the validation and public hostnames pointed at internal IPs. Tests cover the address classification, the model validation, and updates an existing test that used a localhost logout URI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -199,7 +199,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
||||
slug: "logout-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
backchannel_logout_uri: "http://localhost:4000/logout",
|
||||
backchannel_logout_uri: "https://rp.example.com/backchannel-logout",
|
||||
active: true
|
||||
)
|
||||
|
||||
|
||||
38
test/lib/private_address_check_test.rb
Normal file
38
test/lib/private_address_check_test.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
require "test_helper"
|
||||
|
||||
class PrivateAddressCheckTest < ActiveSupport::TestCase
|
||||
# internal_host? — DNS-free checks on IP literals and known hostnames
|
||||
test "flags loopback, private, and link-local IP literals as internal" do
|
||||
%w[
|
||||
127.0.0.1
|
||||
10.0.0.1
|
||||
172.16.5.5
|
||||
192.168.1.1
|
||||
169.254.169.254
|
||||
0.0.0.0
|
||||
::1
|
||||
].each do |host|
|
||||
assert PrivateAddressCheck.internal_host?(host), "expected #{host} to be internal"
|
||||
end
|
||||
end
|
||||
|
||||
test "flags localhost-style hostnames as internal" do
|
||||
assert PrivateAddressCheck.internal_host?("localhost")
|
||||
assert PrivateAddressCheck.internal_host?("foo.localhost")
|
||||
assert PrivateAddressCheck.internal_host?("metadata.google.internal")
|
||||
assert PrivateAddressCheck.internal_host?("")
|
||||
end
|
||||
|
||||
test "does not flag public IP literals as internal" do
|
||||
refute PrivateAddressCheck.internal_host?("8.8.8.8")
|
||||
refute PrivateAddressCheck.internal_host?("1.1.1.1")
|
||||
end
|
||||
|
||||
# resolves_to_internal? on IP literals (no DNS needed) exercises the same
|
||||
# address classification used after resolution.
|
||||
test "resolves_to_internal? classifies IP literals" do
|
||||
assert PrivateAddressCheck.resolves_to_internal?("169.254.169.254")
|
||||
assert PrivateAddressCheck.resolves_to_internal?("127.0.0.1")
|
||||
refute PrivateAddressCheck.resolves_to_internal?("8.8.8.8")
|
||||
end
|
||||
end
|
||||
@@ -56,4 +56,29 @@ class ApplicationTest < ActiveSupport::TestCase
|
||||
tempfile&.close
|
||||
tempfile&.unlink
|
||||
end
|
||||
|
||||
test "rejects backchannel_logout_uri pointing at internal addresses (SSRF guard)" do
|
||||
app = applications(:kavita_app)
|
||||
|
||||
internal_uris = [
|
||||
"http://127.0.0.1/logout",
|
||||
"http://localhost/logout",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"http://10.0.0.5/logout",
|
||||
"http://192.168.1.10/logout"
|
||||
]
|
||||
|
||||
internal_uris.each do |uri|
|
||||
app.backchannel_logout_uri = uri
|
||||
refute app.valid?, "expected #{uri} to be rejected"
|
||||
assert_includes app.errors[:backchannel_logout_uri].join, "private, loopback, or link-local"
|
||||
end
|
||||
end
|
||||
|
||||
test "allows backchannel_logout_uri pointing at a public host" do
|
||||
app = applications(:kavita_app)
|
||||
app.backchannel_logout_uri = "https://relying-party.example.com/backchannel-logout"
|
||||
|
||||
assert app.valid?, app.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user