Fix ForwardAuth fail-open and consent CSRF bypass

Two HIGH-severity findings from the security review:

- ForwardAuth: when no host header was present, /api/verify skipped the
  application lookup and group check entirely, returning 200 with identity
  headers (including all of the user's groups). This bypassed per-domain
  access control. Now fails closed with 403, and the unreachable
  DEFAULT_HEADERS fallback (the bypass path) is removed so headers are
  always scoped to a resolved, active application.

- OIDC: the consent endpoint was in the verify_authenticity_token skip
  list, so a forged cross-site POST could silently grant OAuth scopes.
  Removed :consent from the skip list (the form already embeds the token).

Adds regression tests for both: fail-closed with no identity headers when
host is absent, and 422 on a tokenless consent POST.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 07:52:56 +10:00
parent 2843790cef
commit 703d24e4e4
4 changed files with 46 additions and 19 deletions

View File

@@ -64,26 +64,16 @@ module Api
return render_forbidden("No authentication rule configured for this domain")
end
else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end
headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
when :groups
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
# Fail closed: with no host we cannot resolve an application or evaluate its
# group policy. Emitting identity headers here would bypass all per-domain
# access control, so reject instead.
Rails.logger.info "ForwardAuth: Access denied - no host header present"
return render_forbidden("No host header present")
end
# Reaching here implies a matching, active application was resolved above
# (every other path returns forbidden), so headers are always scoped to it.
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?