mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* 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.
268 lines
11 KiB
Ruby
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
|