Files
clinch/app/models/application.rb
Dan Milne 406a79d9eb 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>
2026-06-11 08:14:45 +10:00

408 lines
13 KiB
Ruby

class Application < ApplicationRecord
has_secure_password :client_secret, validations: false
# Virtual attribute to control client type during creation
# When true, no client_secret will be generated (public client)
attr_accessor :is_public_client
# Virtual setters for TTL fields - accept human-friendly durations
# e.g., "1h", "30m", "1d", or plain numbers "3600"
def access_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
def refresh_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
def id_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
after_commit :bust_forward_auth_cache, if: :forward_auth?
has_one_attached :icon
has_one_attached :icon_dark
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
before_validation :sanitize_svg_icons
after_save :fix_icon_content_types
has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group
has_many :application_user_claims, dependent: :destroy
has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :api_keys, dependent: :destroy
validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false},
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
validates :app_type, presence: true,
inclusion: {in: %w[oidc forward_auth]}
validates :client_id, uniqueness: {allow_nil: true}
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
validates :backchannel_logout_uri, format: {
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
allow_nil: true,
message: "must be a valid HTTP or HTTPS URL"
}
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
# Icon validation using ActiveStorage validators
validate :icon_validation
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) {
normalized = pattern&.strip&.downcase
normalized.blank? ? nil : normalized
}
normalizes :backchannel_logout_uri, with: ->(uri) {
normalized = uri&.strip
normalized.blank? ? nil : normalized
}
before_validation :generate_client_credentials, on: :create, if: :oidc?
# Default header configuration for ForwardAuth
DEFAULT_HEADERS = {
user: "X-Remote-User",
email: "X-Remote-Email",
name: "X-Remote-Name",
username: "X-Remote-Username",
groups: "X-Remote-Groups",
admin: "X-Remote-Admin"
}.freeze
# Scopes
scope :active, -> { where(active: true) }
scope :oidc, -> { where(app_type: "oidc") }
scope :forward_auth, -> { where(app_type: "forward_auth") }
scope :ordered, -> { order(domain_pattern: :asc) }
# Type checks
def oidc?
app_type == "oidc"
end
def forward_auth?
app_type == "forward_auth"
end
# Client type checks (for OIDC)
def public_client?
client_secret_digest.blank?
end
def confidential_client?
!public_client?
end
# PKCE requirement check
# Public clients MUST use PKCE (no client secret to protect auth code)
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
def requires_pkce?
return false unless oidc?
return true if public_client? # Always require PKCE for public clients
require_pkce? # Check the flag for confidential clients
end
# Access control
# Default-deny: an empty allowed_groups list means no one gets in.
# To make an app accessible to "everyone", attach the seeded auto-assign
# group (or any group every user is in).
def user_allowed?(user)
return false unless active?
return false unless user.active?
(user.groups & allowed_groups).any?
end
# OIDC helpers
def parsed_redirect_uris
return [] unless redirect_uris.present?
JSON.parse(redirect_uris)
rescue JSON::ParserError
redirect_uris.split("\n").map(&:strip).reject(&:blank?)
end
def parsed_metadata
return {} unless metadata.present?
JSON.parse(metadata)
rescue JSON::ParserError
{}
end
# ForwardAuth helpers
def parsed_headers_config
return {} unless headers_config.present?
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
rescue JSON::ParserError
{}
end
# Check if a domain matches this application's pattern (for ForwardAuth)
def matches_domain?(domain)
return false if domain.blank? || !forward_auth?
pattern = domain_pattern.gsub(".", '\.')
pattern = pattern.gsub("*", "[^.]*")
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase)
end
# Policy determination based on user status (for ForwardAuth)
def policy_for_user(user)
return "deny" unless active?
return "deny" unless user.active?
if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? "two_factor" : "one_factor"
else
"deny"
end
end
# Get effective header configuration (for ForwardAuth)
def effective_headers
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
end
# Generate headers for a specific user (for ForwardAuth)
def headers_for_user(user)
headers = {}
effective = effective_headers
# Only generate headers that are configured (not set to nil/false)
effective.each do |key, header_name|
next unless header_name.present? # Skip disabled headers
case key
when :user, :email
headers[header_name] = user.email_address
when :name
headers[header_name] = user.name.presence || user.email_address
when :username
headers[header_name] = user.username if user.username.present?
when :groups
headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
when :admin
headers[header_name] = user.admin? ? "true" : "false"
end
end
headers
end
# Check if all headers are disabled (for ForwardAuth)
def headers_disabled?
headers_config.present? && effective_headers.values.all?(&:blank?)
end
# Generate and return a new client secret
def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
save!
secret
end
# Token TTL helper methods (for OIDC)
def access_token_expiry
(access_token_ttl || 3600).seconds.from_now
end
def refresh_token_expiry
(refresh_token_ttl || 2592000).seconds.from_now
end
def id_token_expiry_seconds
id_token_ttl || 3600
end
# Human-readable TTL for display
def access_token_ttl_human
duration_to_human(access_token_ttl || 3600)
end
def refresh_token_ttl_human
duration_to_human(refresh_token_ttl || 2592000)
end
def id_token_ttl_human
duration_to_human(id_token_ttl || 3600)
end
# Get app-specific custom claims for a user
def custom_claims_for_user(user)
app_claim = application_user_claims.find_by(user: user)
app_claim&.parsed_custom_claims || {}
end
# Check if this application supports backchannel logout
def supports_backchannel_logout?
backchannel_logout_uri.present?
end
# Check if a user has an active session with this application
# (i.e., has valid, non-revoked tokens)
def user_has_active_session?(user)
oidc_access_tokens.where(user: user).valid.exists? ||
oidc_refresh_tokens.where(user: user).valid.exists?
end
private
def bust_forward_auth_cache
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
def fix_icon_content_types
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
# Fix SVG content type if it was detected incorrectly
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
attachment.blob.update(content_type: "image/svg+xml")
end
end
end
def sanitize_svg_icons
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
# this point (Active Storage uploads in before_save), so we cannot call
# download — we must read from the pending attachable.
#
# attach below re-sets attachment_changes and would re-fire this callback;
# we skip if the pending attachable is the cleaned hash we just installed
# (tracked by object identity, per-attribute).
@svg_sanitized_attachables ||= {}
ICON_ATTACHMENTS.each do |attr|
change = attachment_changes[attr.to_s]
next unless change
attachable = change.attachable
next if attachable.equal?(@svg_sanitized_attachables[attr])
raw_svg, filename, content_type = read_pending_icon(attachable)
next unless raw_svg
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
sanitized = {
io: StringIO.new(clean_svg),
filename: filename,
content_type: "image/svg+xml"
}
@svg_sanitized_attachables[attr] = sanitized
public_send(attr).attach(sanitized)
end
end
def read_pending_icon(attachable)
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
content = attachable.read
attachable.rewind
[content, attachable.original_filename, attachable.content_type]
when Hash
io = attachable[:io] || attachable["io"]
return [nil, nil, nil] unless io
content = io.read
io.rewind if io.respond_to?(:rewind)
[content,
attachable[:filename] || attachable["filename"],
attachable[:content_type] || attachable["content_type"]]
else
[nil, nil, nil]
end
end
def icon_validation
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
unless allowed_types.include?(attachment.content_type)
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
end
if attachment.blob.byte_size > 2.megabytes
errors.add(attr, "must be less than 2MB")
end
end
end
def duration_to_human(seconds)
if seconds < 3600
"#{seconds / 60} minutes"
elsif seconds < 86400
"#{seconds / 3600} hours"
else
"#{seconds / 86400} days"
end
end
def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate client secret only for confidential clients
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
if new_record? && client_secret.blank? && !is_public_client_selected?
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
end
end
# Check if the user selected public client option
def is_public_client_selected?
ActiveModel::Type::Boolean.new.cast(is_public_client)
end
def backchannel_logout_uri_must_be_https_in_production
return unless Rails.env.production?
return unless backchannel_logout_uri.present?
begin
uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == "https"
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs
end
end
# SSRF guard: the backchannel logout URI is dialled server-side on every user
# logout, so it must not target internal infrastructure (loopback, private
# ranges, or the link-local cloud metadata endpoint). This is the fast,
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
def backchannel_logout_uri_not_internal
uri = URI.parse(backchannel_logout_uri)
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs
end
end