Files
clinch/test/integration/forward_auth_integration_test.rb
Dan Milne 84ed462f40 Require CLINCH_HOST in deployed environments; drop request-host fallback
determine_base_url fell back to request.host when CLINCH_HOST was unset. Rails
resolves request.host from X-Forwarded-Host behind a trusted proxy, so a spoofed
header could make the forward-auth login redirect point at an attacker origin
(host-header phishing).

- Add config/initializers/clinch_host.rb: fail fast at boot in any non-local
  environment when CLINCH_HOST is blank. It anchors the OIDC issuer, WebAuthn
  RP ID, and login redirect, so it must be explicit, never inferred.
- determine_base_url now uses CLINCH_HOST (guaranteed in production) with a safe
  localhost default for dev/test, and never reads the request host.
- Simplify the spoofed-host regression test now that the fallback is safe.

Verified: production boot aborts with a clear message when CLINCH_HOST is blank,
and boots normally when set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:04:42 +10:00

276 lines
11 KiB
Ruby

require "test_helper"
class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@admin_user = users(:two)
@group = groups(:one)
@group2 = groups(:two)
# Create a forward_auth application for test.example.com
@test_app = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "forward_auth",
domain_pattern: "test.example.com",
active: true
)
grant_everyone_access(@test_app)
end
# Basic Authentication Flow Tests
test "complete authentication flow: unauthenticated to authenticated" do
# Step 1: Unauthenticated request should redirect
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_match %r{/signin}, response.location
assert_equal "No session cookie", response.headers["x-auth-reason"]
# Step 2: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 303
# Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id]
# Step 3: Authenticated request should succeed
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
test "session expiration handling" do
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Manually expire the session (get the most recent session for this user)
session = Session.where(user: @user).order(created_at: :desc).first
assert session, "No session found for user"
session.update!(expires_at: 1.hour.ago)
# Request should fail and redirect to login
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"]
end
# Domain and Rule Integration Tests
test "different domain patterns with same session" do
# Create test rules
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test wildcard domain
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
# Test exact domain
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
# Test non-matching domain (should use defaults)
get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
test "group-based access control integration" do
# Create restricted rule
restricted_rule = Application.create!(name: "Restricted App", slug: "restricted-app", app_type: "forward_auth", domain_pattern: "restricted.example.com", active: true)
restricted_rule.allowed_groups << @group
# Sign in user without group
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Should be denied access
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 403
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
# Add user to group
@user.groups << @group
# Should now be allowed
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
# Header Configuration Integration Tests
test "different header configurations with same user" do
# Create applications with different configs
grant_everyone_access Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
grant_everyone_access Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
)
grant_everyone_access Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
)
# Add user to groups
@user.groups << @group
@user.groups << @group2
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test default headers
get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"}
assert_response 200
# Rails normalizes header keys to lowercase
assert_equal @user.email_address, response.headers["x-remote-user"]
assert response.headers.key?("x-remote-groups")
groups_in_header = response.headers["x-remote-groups"].split(",").sort
expected_groups = @user.groups.reload.map(&:name).sort
assert_equal expected_groups, groups_in_header
# Test custom headers
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200
# Custom headers are also normalized to lowercase
assert_equal @user.email_address, response.headers["x-webauth-user"]
assert response.headers.key?("x-webauth-roles")
assert_equal expected_groups, response.headers["x-webauth-roles"].split(",").sort
# Test no headers
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200
# Check that no auth-related headers are present (excluding security headers)
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
assert_empty auth_headers
end
# Redirect URL Integration Tests
test "unauthenticated request redirects to signin with parameters" do
# grafana.example.com must be a registered forward-auth app for its URL to be
# honoured as a redirect target (otherwise it would be an open-redirect vector).
grant_everyone_access Application.create!(name: "Grafana", slug: "grafana", app_type: "forward_auth", domain_pattern: "grafana.example.com", active: true)
# Test that unauthenticated requests redirect to signin with rd and rm parameters
get "/api/verify", headers: {
"X-Forwarded-Host" => "grafana.example.com"
}, params: {
rd: "https://grafana.example.com/dashboard",
rm: "GET"
}
assert_response 302
location = response.location
# Should redirect to signin with parameters (rd contains the original URL)
assert_includes location, "/signin"
assert_includes location, "rd="
assert_includes location, "rm=GET"
# The rd parameter should contain the original grafana.example.com URL
assert_includes location, "grafana.example.com"
end
test "spoofed X-Forwarded-Host is not reflected as a redirect target" do
# No forward-auth app exists for evil.com, and no valid rd is supplied. The
# attacker-controlled host must NOT be stored or reflected into the signin URL,
# and base_url must come from CLINCH_HOST (or the safe localhost default in
# test) rather than the request host.
get "/api/verify", headers: {
"X-Forwarded-Host" => "evil.com",
"X-Forwarded-Uri" => "/steal"
}
assert_response 302
assert_match %r{/signin}, response.location
refute_includes response.location, "evil.com"
refute_match(/evil\.com/, session[:return_to_after_authenticating].to_s)
end
test "return URL functionality after authentication" do
# app.example.com must be a registered forward-auth app for its URL to be
# honoured as a redirect target.
grant_everyone_access Application.create!(name: "App FA", slug: "app-fa", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Initial request should set return URL
get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/admin"
}, params: {rd: "https://app.example.com/admin"}
assert_response 302
location = response.location
# Should contain the redirect URL parameter
assert_includes location, "rd="
assert_includes location, CGI.escape("https://app.example.com/admin")
# Store session return URL
return_to_after_authenticating = session[:return_to_after_authenticating]
assert_equal "https://app.example.com/admin", return_to_after_authenticating
end
# Multiple User Scenarios Integration Tests
test "multiple users with different access levels" do
regular_user = users(:one)
admin_user = users(:two)
# Create restricted rule
grant_everyone_access Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com",
active: true,
headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"}
)
# Test regular user
post "/signin", params: {email_address: regular_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal regular_user.email_address, response.headers["x-admin-user"]
# Sign out
delete "/session"
# Test admin user
post "/signin", params: {email_address: admin_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal admin_user.email_address, response.headers["x-admin-user"]
assert_equal "true", response.headers["x-admin-flag"]
end
# Security Integration Tests
test "session hijacking prevention" do
# User A signs in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Verify User A can access protected resources
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
user_a_session_id = Session.where(user: @user).last.id
# Reset integration test session (but keep User A's session in database)
reset!
# User B signs in (creates a new session)
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
# Verify User B can access protected resources
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
user_b_session_id = Session.where(user: @admin_user).last.id
# Verify both sessions still exist in the database
assert Session.exists?(user_a_session_id), "User A's session should still exist"
assert Session.exists?(user_b_session_id), "User B's session should still exist"
end
end