Files
sure/config/initializers/00_ssl.rb
BitToby ba6e286b41 feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)
* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
2026-02-06 18:04:03 +01:00

268 lines
11 KiB
Ruby

# frozen_string_literal: true
require "openssl"
require "fileutils"
# Centralized SSL/TLS configuration for outbound HTTPS connections.
#
# This enables support for self-signed certificates in self-hosted environments
# where servers use internal CAs or self-signed certificates.
#
# Environment Variables:
# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
# SSL_VERIFY - Set to "false" to disable SSL verification (NOT RECOMMENDED for production)
# SSL_DEBUG - Set to "true" to enable verbose SSL logging
#
# Example usage in docker-compose.yml:
# environment:
# SSL_CA_FILE: /certs/my-ca.crt
# volumes:
# - ./my-ca.crt:/certs/my-ca.crt:ro
#
# Security Warning:
# Disabling SSL verification (SSL_VERIFY=false) removes protection against
# man-in-the-middle attacks. Only use this for development/testing environments.
#
# IMPORTANT: When a valid SSL_CA_FILE is provided, this initializer sets the
# SSL_CERT_FILE environment variable to a combined CA bundle (system CAs + custom CA).
# This is a *global* side-effect that affects ALL SSL connections in the Ruby process,
# including gems that do not go through SslConfigurable (e.g. openid_connect).
# This is intentional — it ensures OIDC discovery, webhook callbacks, and any other
# outbound HTTPS connection trusts both public CAs and the user's custom CA.
# Path for the combined CA bundle file (predictable location for debugging)
COMBINED_CA_BUNDLE_PATH = Rails.root.join("tmp", "ssl_ca_bundle.pem").freeze
# Boot-time helper for SSL certificate validation and bundle creation.
#
# This is intentionally a standalone module (not nested under SslConfigurable)
# because SslConfigurable is autoloaded by Zeitwerk from app/models/concerns/.
# Reopening that module here at boot time would register the constant before
# Zeitwerk, preventing the real concern (with httparty_ssl_options, etc.) from
# ever being loaded — causing NameError at class load time in providers.
module SslInitializerHelper
module_function
# PEM certificate format markers (X.509 standard)
PEM_CERT_BEGIN = "-----BEGIN CERTIFICATE-----"
PEM_CERT_END = "-----END CERTIFICATE-----"
# Validates a CA certificate file.
# Supports single certs and multi-cert PEM bundles (CA chains).
#
# @param path [String] Path to the CA certificate file
# @return [Hash] Validation result with :path, :valid, and :error keys
def validate_ca_certificate_file(path)
result = { path: nil, valid: false, error: nil }
unless File.exist?(path)
result[:error] = "File not found: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not found: #{path}")
return result
end
unless File.readable?(path)
result[:error] = "File not readable: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not readable: #{path}")
return result
end
unless File.file?(path)
result[:error] = "Path is not a file: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but is not a file: #{path}")
return result
end
# Sanity check file size (CA certs should be < 1MB)
file_size = File.size(path)
if file_size > 1_000_000
result[:error] = "File too large (#{file_size} bytes) - expected a PEM certificate"
Rails.logger.warn("[SSL] SSL_CA_FILE is unexpectedly large: #{path} (#{file_size} bytes)")
return result
end
# Validate PEM format using standard X.509 markers
content = File.read(path)
unless content.include?(PEM_CERT_BEGIN)
result[:error] = "Invalid PEM format - missing BEGIN CERTIFICATE marker"
Rails.logger.warn("[SSL] SSL_CA_FILE does not appear to be a valid PEM certificate: #{path}")
return result
end
unless content.include?(PEM_CERT_END)
result[:error] = "Invalid PEM format - missing END CERTIFICATE marker"
Rails.logger.warn("[SSL] SSL_CA_FILE has incomplete PEM format: #{path}")
return result
end
# Parse and validate every certificate in the PEM file.
# OpenSSL::X509::Certificate.new only parses the first PEM block,
# so multi-cert bundles (CA chains) need per-block validation.
begin
pem_blocks = content.scan(/#{PEM_CERT_BEGIN}[\s\S]+?#{PEM_CERT_END}/)
raise OpenSSL::X509::CertificateError, "No certificates found in PEM file" if pem_blocks.empty?
pem_blocks.each_with_index do |pem, index|
OpenSSL::X509::Certificate.new(pem)
rescue OpenSSL::X509::CertificateError => e
raise OpenSSL::X509::CertificateError, "Certificate #{index + 1} of #{pem_blocks.size} is invalid: #{e.message}"
end
result[:path] = path
result[:valid] = true
rescue OpenSSL::X509::CertificateError => e
result[:error] = "Invalid certificate: #{e.message}"
Rails.logger.warn("[SSL] SSL_CA_FILE contains invalid certificate: #{e.message}")
end
result
end
# Finds the system CA certificate bundle path using OpenSSL's detection
#
# @return [String, nil] Path to system CA bundle or nil if not found
def find_system_ca_bundle
# First, check if SSL_CERT_FILE is already set (user may have their own bundle)
existing_cert_file = ENV["SSL_CERT_FILE"]
if existing_cert_file.present? && File.exist?(existing_cert_file) && File.readable?(existing_cert_file)
return existing_cert_file
end
# Use OpenSSL's built-in CA file detection
openssl_ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
if openssl_ca_file.present? && File.exist?(openssl_ca_file) && File.readable?(openssl_ca_file)
return openssl_ca_file
end
# Use OpenSSL's default certificate directory as fallback
openssl_ca_dir = OpenSSL::X509::DEFAULT_CERT_DIR
if openssl_ca_dir.present? && Dir.exist?(openssl_ca_dir)
# Look for common bundle file names in the certificate directory
%w[ca-certificates.crt ca-bundle.crt cert.pem].each do |bundle_name|
bundle_path = File.join(openssl_ca_dir, bundle_name)
return bundle_path if File.exist?(bundle_path) && File.readable?(bundle_path)
end
end
nil
end
# Creates a combined CA bundle with system CAs and custom CA.
# Writes to a predictable path (tmp/ssl_ca_bundle.pem) for easy debugging
# and to avoid Tempfile GC lifecycle issues.
#
# @param custom_ca_path [String] Path to the custom CA certificate
# @param output_path [String] Where to write the combined bundle
# @return [String, nil] Path to the combined bundle, or nil on failure
def create_combined_ca_bundle(custom_ca_path, output_path: COMBINED_CA_BUNDLE_PATH)
system_ca = find_system_ca_bundle
unless system_ca
Rails.logger.warn("[SSL] Could not find system CA bundle - using custom CA only")
return nil
end
begin
system_content = File.read(system_ca)
custom_content = File.read(custom_ca_path)
# Ensure the parent directory exists
FileUtils.mkdir_p(File.dirname(output_path))
File.write(output_path, system_content + "\n# Custom CA Certificate\n" + custom_content)
Rails.logger.info("[SSL] Created combined CA bundle: #{output_path}")
Rails.logger.info("[SSL] - System CA source: #{system_ca}")
Rails.logger.info("[SSL] - Custom CA source: #{custom_ca_path}")
output_path.to_s
rescue StandardError => e
Rails.logger.error("[SSL] Failed to create combined CA bundle: #{e.message}")
nil
end
end
# Logs SSL configuration summary at startup
#
# @param ssl_config [ActiveSupport::OrderedOptions] SSL configuration
def log_ssl_configuration(ssl_config)
if ssl_config.debug
Rails.logger.info("[SSL] Debug mode enabled - verbose SSL logging active")
end
if ssl_config.ca_file.present?
if ssl_config.ca_file_valid
Rails.logger.info("[SSL] Custom CA certificate configured and validated: #{ssl_config.ca_file}")
else
Rails.logger.error("[SSL] Custom CA certificate configured but invalid: #{ssl_config.ca_file_error}")
end
end
unless ssl_config.verify
Rails.logger.warn("[SSL] " + "=" * 60)
Rails.logger.warn("[SSL] WARNING: SSL verification is DISABLED")
Rails.logger.warn("[SSL] This is insecure and should only be used for development/testing")
Rails.logger.warn("[SSL] Set SSL_VERIFY=true or remove the variable for production")
Rails.logger.warn("[SSL] " + "=" * 60)
end
if ssl_config.debug
Rails.logger.info("[SSL] Configuration summary:")
Rails.logger.info("[SSL] - SSL verification: #{ssl_config.verify ? 'ENABLED' : 'DISABLED'}")
Rails.logger.info("[SSL] - Custom CA file: #{ssl_config.ca_file || 'not configured'}")
Rails.logger.info("[SSL] - CA file valid: #{ssl_config.ca_file_valid}")
Rails.logger.info("[SSL] - Combined CA bundle: #{ssl_config.combined_ca_bundle || 'not created'}")
Rails.logger.info("[SSL] - SSL_CERT_FILE: #{ENV['SSL_CERT_FILE'] || 'not set'}")
end
end
end
# Configure SSL settings
Rails.application.configure do
config.x.ssl ||= ActiveSupport::OrderedOptions.new
truthy_values = %w[1 true yes on].freeze
falsy_values = %w[0 false no off].freeze
# Debug mode for verbose SSL logging
debug_env = ENV["SSL_DEBUG"].to_s.strip.downcase
config.x.ssl.debug = truthy_values.include?(debug_env)
# SSL verification (default: true)
verify_env = ENV["SSL_VERIFY"].to_s.strip.downcase
config.x.ssl.verify = !falsy_values.include?(verify_env)
# Custom CA certificate file for trusting self-signed certificates
ca_file = ENV["SSL_CA_FILE"].presence
config.x.ssl.ca_file = nil
config.x.ssl.ca_file_valid = false
if ca_file
ca_file_status = SslInitializerHelper.validate_ca_certificate_file(ca_file)
config.x.ssl.ca_file = ca_file_status[:path]
config.x.ssl.ca_file_valid = ca_file_status[:valid]
config.x.ssl.ca_file_error = ca_file_status[:error]
# Create combined CA bundle and set SSL_CERT_FILE for global SSL configuration.
#
# This sets ENV["SSL_CERT_FILE"] globally so that ALL Ruby SSL connections
# (including gems like openid_connect that bypass SslConfigurable) will trust
# both system CAs (for public services) and the custom CA (for self-signed services).
if ca_file_status[:valid]
combined_path = SslInitializerHelper.create_combined_ca_bundle(ca_file_status[:path])
if combined_path
config.x.ssl.combined_ca_bundle = combined_path
ENV["SSL_CERT_FILE"] = combined_path
Rails.logger.info("[SSL] Set SSL_CERT_FILE=#{combined_path} for global SSL configuration")
else
# Fallback: just use the custom CA (may break connections to public services)
Rails.logger.warn("[SSL] Could not create combined CA bundle, using custom CA only. " \
"Connections to public services (not using your custom CA) may fail.")
ENV["SSL_CERT_FILE"] = ca_file_status[:path]
end
end
end
# Log configuration summary at startup
SslInitializerHelper.log_ssl_configuration(config.x.ssl)
end