diff --git a/.env.local.example b/.env.local.example index b9ddcabf3..75a8de778 100644 --- a/.env.local.example +++ b/.env.local.example @@ -48,3 +48,36 @@ LANGFUSE_HOST = https://cloud.langfuse.com # Set to `true` to get error messages rendered in the /chats UI AI_DEBUG_MODE = + +# ============================================================================= +# SSL/TLS Configuration for Self-Signed Certificates +# ============================================================================= +# Use these settings when connecting to services with self-signed or internal +# CA certificates (e.g., self-hosted Keycloak, Authentik, or AI endpoints). +# +# SSL_CA_FILE: Path to custom CA certificate file (PEM format) +# - The certificate that signed your server's SSL certificate +# - Must be readable by the application +# - Will be validated at startup +# SSL_CA_FILE = /certs/my-ca.crt +# +# SSL_VERIFY: Enable/disable SSL certificate verification +# - Default: true (verification enabled) +# - Set to "false" ONLY for development/testing +# - WARNING: Disabling removes protection against man-in-the-middle attacks +# SSL_VERIFY = true +# +# SSL_DEBUG: Enable verbose SSL logging for troubleshooting +# - Default: false +# - When enabled, logs detailed SSL connection information +# - Useful for diagnosing certificate issues +# SSL_DEBUG = false +# +# Example docker-compose.yml configuration: +# services: +# app: +# environment: +# SSL_CA_FILE: /certs/my-ca.crt +# SSL_DEBUG: "true" +# volumes: +# - ./my-ca.crt:/certs/my-ca.crt:ro diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1e0863a52..009a45eab 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + extend SslConfigurable + before_action :set_session, only: :destroy skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start] @@ -305,7 +307,7 @@ class SessionsController < ApplicationController if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present? begin discovery_url = discovery_url_for(provider_config[:issuer]) - response = Faraday.get(discovery_url) do |req| + response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req| req.options.timeout = 5 req.options.open_timeout = 3 end diff --git a/app/models/concerns/ssl_configurable.rb b/app/models/concerns/ssl_configurable.rb new file mode 100644 index 000000000..a7bd1f337 --- /dev/null +++ b/app/models/concerns/ssl_configurable.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Provides centralized SSL configuration for HTTP clients. +# +# This module enables support for self-signed certificates in self-hosted +# environments by reading configuration from Rails.configuration.x.ssl. +# +# Features: +# - Custom CA certificate support for self-signed certificates +# - Optional SSL verification bypass (for development/testing only) +# - Debug logging for troubleshooting SSL issues +# +# Usage (extend for class methods — the only supported pattern): +# class MyHttpClient +# extend SslConfigurable +# +# def self.make_request +# Faraday.new(url, ssl: faraday_ssl_options) { |f| ... } +# end +# end +# +# Environment Variables (configured in config/initializers/00_ssl.rb): +# SSL_CA_FILE - Path to custom CA certificate file (PEM format) +# SSL_VERIFY - Set to "false" to disable SSL verification +# SSL_DEBUG - Set to "true" to enable verbose SSL logging +module SslConfigurable + # Returns SSL options hash for Faraday connections + # + # @return [Hash] SSL options for Faraday + # @example + # Faraday.new(url, ssl: faraday_ssl_options) do |f| + # f.request :json + # f.response :raise_error + # end + def faraday_ssl_options + options = {} + + options[:verify] = ssl_verify? + + if ssl_ca_file.present? + options[:ca_file] = ssl_ca_file + log_ssl_debug("Faraday SSL: Using custom CA file: #{ssl_ca_file}") + end + + log_ssl_debug("Faraday SSL: Verification disabled") unless ssl_verify? + log_ssl_debug("Faraday SSL options: #{options.inspect}") if options.present? + + options + end + + # Returns SSL options hash for HTTParty requests + # + # @return [Hash] SSL options for HTTParty + # @example + # class MyProvider + # include HTTParty + # extend SslConfigurable + # default_options.merge!(httparty_ssl_options) + # end + def httparty_ssl_options + options = { verify: ssl_verify? } + + if ssl_ca_file.present? + options[:ssl_ca_file] = ssl_ca_file + log_ssl_debug("HTTParty SSL: Using custom CA file: #{ssl_ca_file}") + end + + log_ssl_debug("HTTParty SSL: Verification disabled") unless ssl_verify? + + options + end + + # Returns SSL verify mode for Net::HTTP + # + # @return [Integer] OpenSSL verify mode constant (VERIFY_PEER or VERIFY_NONE) + # @example + # http = Net::HTTP.new(uri.host, uri.port) + # http.use_ssl = true + # http.verify_mode = net_http_verify_mode + # http.ca_file = ssl_ca_file if ssl_ca_file.present? + def net_http_verify_mode + mode = ssl_verify? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE + log_ssl_debug("Net::HTTP verify mode: #{mode == OpenSSL::SSL::VERIFY_PEER ? 'VERIFY_PEER' : 'VERIFY_NONE'}") + mode + end + + # Returns CA file path if configured + # + # @return [String, nil] Path to CA file or nil if not configured + def ssl_ca_file + ssl_configuration.ca_file + end + + # Returns whether SSL verification is enabled + # nil or true both mean verification is enabled; only explicit false disables it + # + # @return [Boolean] true if SSL verification is enabled + def ssl_verify? + ssl_configuration.verify != false + end + + # Returns whether SSL debug logging is enabled + # + # @return [Boolean] true if debug logging is enabled + def ssl_debug? + ssl_configuration.debug == true + end + + private + + # Returns the SSL configuration from Rails + # + # @return [ActiveSupport::OrderedOptions] SSL configuration + def ssl_configuration + Rails.configuration.x.ssl + end + + # Logs a debug message if SSL debug mode is enabled + # + # @param message [String] Message to log + def log_ssl_debug(message) + return unless ssl_debug? + + Rails.logger.debug("[SSL Debug] #{message}") + end +end diff --git a/app/models/eval/langfuse/client.rb b/app/models/eval/langfuse/client.rb index ceac2fb69..75701e1cb 100644 --- a/app/models/eval/langfuse/client.rb +++ b/app/models/eval/langfuse/client.rb @@ -1,9 +1,30 @@ class Eval::Langfuse::Client + extend SslConfigurable + BASE_URLS = { us: "https://us.cloud.langfuse.com/api/public", eu: "https://cloud.langfuse.com/api/public" }.freeze + # OpenSSL 3.x version threshold for CRL workaround + # See: https://github.com/ruby/openssl/issues/619 + OPENSSL_3_VERSION = 0x30000000 + + # CRL-related OpenSSL error codes that can be safely bypassed + # These errors occur when CRL (Certificate Revocation List) is unavailable + def self.crl_errors + @crl_errors ||= begin + errors = [ + OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL, + OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED, + OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID + ] + # V_ERR_UNABLE_TO_GET_CRL_ISSUER may not exist in all OpenSSL versions + errors << OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER if defined?(OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER) + errors.freeze + end + end + class Error < StandardError; end class ConfigurationError < Error; end class ApiError < Error @@ -176,12 +197,24 @@ class Eval::Langfuse::Client http.read_timeout = 30 http.open_timeout = 10 - # Fix for OpenSSL 3.x CRL checking issues + # Apply SSL configuration from centralized config + http.verify_mode = self.class.net_http_verify_mode + http.ca_file = self.class.ssl_ca_file if self.class.ssl_ca_file.present? + + # Fix for OpenSSL 3.x CRL checking issues (only when verification is enabled) # See: https://github.com/ruby/openssl/issues/619 - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - if OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30000000 - # Disable CRL checking which can fail on some certificates - http.verify_callback = ->(_preverify_ok, _store_ctx) { true } + # Only bypass CRL-specific errors, not all certificate verification + if self.class.ssl_verify? && OpenSSL::OPENSSL_VERSION_NUMBER >= OPENSSL_3_VERSION + crl_error_codes = self.class.crl_errors + http.verify_callback = ->(preverify_ok, store_ctx) { + # Bypass only CRL-specific errors (these fail when CRL is unavailable) + # For all other errors, preserve the original verification result + if crl_error_codes.include?(store_ctx.error) + true + else + preverify_ok + end + } end response = http.request(request) diff --git a/app/models/provider/coinbase.rb b/app/models/provider/coinbase.rb index 73a10c163..ec68cda02 100644 --- a/app/models/provider/coinbase.rb +++ b/app/models/provider/coinbase.rb @@ -1,4 +1,7 @@ class Provider::Coinbase + include HTTParty + extend SslConfigurable + class Error < StandardError; end class AuthenticationError < Error; end class RateLimitError < Error; end @@ -7,6 +10,9 @@ class Provider::Coinbase # CDP API base URL API_BASE_URL = "https://api.coinbase.com".freeze + base_uri API_BASE_URL + default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options)) + attr_reader :api_key, :api_secret def initialize(api_key:, api_secret:) @@ -57,7 +63,8 @@ class Provider::Coinbase # Get spot price for a currency pair (e.g., "BTC-USD") # This is a public endpoint that doesn't require authentication def get_spot_price(currency_pair) - response = HTTParty.get("#{API_BASE_URL}/v2/prices/#{currency_pair}/spot", timeout: 10) + # Use self.class.get to inherit class-level SSL and timeout defaults + response = self.class.get("/v2/prices/#{currency_pair}/spot", timeout: 10) handle_response(response)["data"] rescue => e Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}") @@ -78,13 +85,13 @@ class Provider::Coinbase private def get(path, params: {}) - url = "#{API_BASE_URL}#{path}" + url = path url += "?#{params.to_query}" if params.any? - response = HTTParty.get( + # Use self.class.get to inherit class-level SSL and timeout defaults + response = self.class.get( url, - headers: auth_headers("GET", path), - timeout: 30 + headers: auth_headers("GET", path) ) handle_response(response) @@ -101,16 +108,14 @@ class Provider::Coinbase uri = URI.parse(next_uri) current_path = uri.path current_path += "?#{uri.query}" if uri.query - url = "#{API_BASE_URL}#{current_path}" else current_path = path - url = "#{API_BASE_URL}#{path}" end - response = HTTParty.get( - url, - headers: auth_headers("GET", current_path.split("?").first), - timeout: 30 + # Use self.class.get to inherit class-level SSL and timeout defaults + response = self.class.get( + current_path, + headers: auth_headers("GET", current_path.split("?").first) ) data = handle_response(response) diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index 0fc0d8695..3ffa814b4 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -2,6 +2,7 @@ # Handles authentication and requests to the CoinStats OpenAPI. class Provider::Coinstats < Provider include HTTParty + extend SslConfigurable # Subclass so errors caught in this provider are raised as Provider::Coinstats::Error Error = Class.new(Provider::Error) @@ -9,7 +10,7 @@ class Provider::Coinstats < Provider BASE_URL = "https://openapiv1.coinstats.app" headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)" - default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) attr_reader :api_key diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb index 09770a47a..eb7ebd025 100644 --- a/app/models/provider/enable_banking.rb +++ b/app/models/provider/enable_banking.rb @@ -2,11 +2,12 @@ require "cgi" class Provider::EnableBanking include HTTParty + extend SslConfigurable BASE_URL = "https://api.enablebanking.com".freeze headers "User-Agent" => "Sure Finance Enable Banking Client" - default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) attr_reader :application_id, :private_key diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb index b827368d1..f849bc5fd 100644 --- a/app/models/provider/lunchflow.rb +++ b/app/models/provider/lunchflow.rb @@ -1,8 +1,9 @@ class Provider::Lunchflow include HTTParty + extend SslConfigurable headers "User-Agent" => "Sure Finance Lunch Flow Client" - default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) attr_reader :api_key, :base_url diff --git a/app/models/provider/mercury.rb b/app/models/provider/mercury.rb index c78c14c1b..566dc0bd7 100644 --- a/app/models/provider/mercury.rb +++ b/app/models/provider/mercury.rb @@ -1,8 +1,9 @@ class Provider::Mercury include HTTParty + extend SslConfigurable headers "User-Agent" => "Sure Finance Mercury Client" - default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) attr_reader :token, :base_url diff --git a/app/models/provider/simplefin.rb b/app/models/provider/simplefin.rb index 5ceb2ecad..b878106bb 100644 --- a/app/models/provider/simplefin.rb +++ b/app/models/provider/simplefin.rb @@ -5,9 +5,10 @@ class Provider::Simplefin # These are centralized in `Rails.configuration.x.simplefin.*` via # `config/initializers/simplefin.rb`. include HTTParty + extend SslConfigurable headers "User-Agent" => "Sure Finance SimpleFin Client" - default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) # Retry configuration for transient network failures MAX_RETRIES = 3 @@ -34,8 +35,9 @@ class Provider::Simplefin # Use retry logic for transient network failures during token claim # Claim should be fast; keep request-path latency bounded. + # Use self.class.post to inherit class-level SSL and timeout defaults response = with_retries("POST /claim", max_retries: 1, sleep: false) do - HTTParty.post(claim_url, timeout: 15) + self.class.post(claim_url, timeout: 15) end case response.code @@ -71,8 +73,9 @@ class Provider::Simplefin # The access URL already contains HTTP Basic Auth credentials # Use retry logic with exponential backoff for transient network failures + # Use self.class.get to inherit class-level SSL and timeout defaults response = with_retries("GET /accounts") do - HTTParty.get(accounts_url) + self.class.get(accounts_url) end case response.code @@ -98,7 +101,8 @@ class Provider::Simplefin end def get_info(base_url) - response = HTTParty.get("#{base_url}/info") + # Use self.class.get to inherit class-level SSL and timeout defaults + response = self.class.get("#{base_url}/info") case response.code when 200 diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index fef92e1bc..d360e5189 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -1,5 +1,6 @@ class Provider::TwelveData < Provider include ExchangeRateConcept, SecurityConcept + extend SslConfigurable # Subclass so errors caught in this provider are raised as Provider::TwelveData::Error Error = Class.new(Provider::Error) @@ -234,7 +235,7 @@ class Provider::TwelveData < Provider end def client - @client ||= Faraday.new(url: base_url) do |faraday| + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| faraday.request(:retry, { max: 2, interval: 0.05, diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index 626b4312f..75b82520c 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -1,5 +1,6 @@ class Provider::YahooFinance < Provider include ExchangeRateConcept, SecurityConcept + extend SslConfigurable # Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error Error = Class.new(Provider::Error) @@ -494,7 +495,7 @@ class Provider::YahooFinance < Provider end def client - @client ||= Faraday.new(url: base_url) do |faraday| + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| faraday.request(:retry, { max: max_retries, interval: retry_interval, @@ -609,7 +610,7 @@ class Provider::YahooFinance < Provider # Client for authentication requests (no error raising - fc.yahoo.com returns 404 but sets cookie) def auth_client - @auth_client ||= Faraday.new do |faraday| + @auth_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) do |faraday| faraday.headers["User-Agent"] = random_user_agent faraday.headers["Accept"] = "*/*" faraday.headers["Accept-Language"] = "en-US,en;q=0.9" @@ -620,7 +621,7 @@ class Provider::YahooFinance < Provider # Client for authenticated requests (includes cookie header) def authenticated_client(cookie) - Faraday.new(url: base_url) do |faraday| + Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| faraday.request(:retry, { max: max_retries, interval: retry_interval, diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index d41b4c0e8..8bf8dbc09 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -2,6 +2,7 @@ class SsoProvider < ApplicationRecord include Encryptable + extend SslConfigurable # Encrypt sensitive credentials if ActiveRecord encryption is configured if encryption_ready? @@ -116,7 +117,7 @@ class SsoProvider < ApplicationRecord begin discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration" - response = Faraday.get(discovery_url) do |req| + response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req| req.options.timeout = 5 req.options.open_timeout = 3 end diff --git a/app/models/sso_provider_tester.rb b/app/models/sso_provider_tester.rb index 0464088c4..7b27f08b7 100644 --- a/app/models/sso_provider_tester.rb +++ b/app/models/sso_provider_tester.rb @@ -2,6 +2,8 @@ # Tests SSO provider configuration by validating discovery endpoints class SsoProviderTester + extend SslConfigurable + attr_reader :provider, :result Result = Struct.new(:success?, :message, :details, keyword_init: true) @@ -34,7 +36,7 @@ class SsoProviderTester discovery_url = build_discovery_url(provider.issuer) begin - response = Faraday.get(discovery_url) do |req| + response = faraday_client.get(discovery_url) do |req| req.options.timeout = 10 req.options.open_timeout = 5 end @@ -146,7 +148,7 @@ class SsoProviderTester metadata_url = provider.settings&.dig("idp_metadata_url") if metadata_url.present? begin - response = Faraday.get(metadata_url) do |req| + response = faraday_client.get(metadata_url) do |req| req.options.timeout = 10 req.options.open_timeout = 5 end @@ -198,4 +200,8 @@ class SsoProviderTester "#{issuer}/.well-known/openid-configuration" end end + + def faraday_client + @faraday_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) + end end diff --git a/config/initializers/00_ssl.rb b/config/initializers/00_ssl.rb new file mode 100644 index 000000000..acebcfec7 --- /dev/null +++ b/config/initializers/00_ssl.rb @@ -0,0 +1,267 @@ +# 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 diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 1b4d301b3..449d70504 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -77,7 +77,14 @@ Rails.application.config.middleware.use OmniAuth::Builder do client_options: { identifier: client_id, secret: client_secret, - redirect_uri: redirect_uri + redirect_uri: redirect_uri, + ssl: begin + ssl_config = Rails.configuration.x.ssl + ssl_opts = {} + ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present? + ssl_opts[:verify] = false if ssl_config&.verify == false + ssl_opts + end } } diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md index 3aa676b8a..9100bb5f9 100644 --- a/docs/hosting/oidc.md +++ b/docs/hosting/oidc.md @@ -471,7 +471,68 @@ When adding an OIDC provider, Sure validates the `.well-known/openid-configurati - Ensure the issuer URL is correct and accessible - Check firewall rules allow outbound HTTPS to the issuer - Verify the issuer returns valid JSON with an `issuer` field -- For self-signed certificates, you may need to configure SSL verification +- For self-signed certificates, configure SSL verification (see below) + +### Self-signed certificate support + +If your identity provider uses self-signed certificates or certificates from an internal CA, configure the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `SSL_CA_FILE` | Path to custom CA certificate (PEM format) | Not set | +| `SSL_VERIFY` | Enable/disable SSL verification | `true` | +| `SSL_DEBUG` | Enable verbose SSL logging | `false` | + +**Option 1: Custom CA certificate (recommended)** + +Mount your CA certificate into the container and set `SSL_CA_FILE`: + +```yaml +# docker-compose.yml +services: + app: + environment: + SSL_CA_FILE: /certs/my-ca.crt + volumes: + - ./my-ca.crt:/certs/my-ca.crt:ro +``` + +The certificate file must: +- Be in PEM format (starts with `-----BEGIN CERTIFICATE-----`) +- Be readable by the application +- Be the CA certificate that signed your server's SSL certificate + +**Option 2: Disable SSL verification (NOT recommended for production)** + +For testing only, you can disable SSL verification: + +```bash +SSL_VERIFY=false +``` + +**Warning:** Disabling SSL verification removes protection against man-in-the-middle attacks. Only use this for development or testing environments. + +**Troubleshooting SSL issues** + +Enable debug logging to diagnose SSL certificate problems: + +```bash +SSL_DEBUG=true +``` + +This will log detailed information about SSL connections, including: +- Which CA file is being used +- SSL verification mode +- Detailed error messages with resolution hints + +Common error messages and solutions: + +| Error | Solution | +|-------|----------| +| `self-signed certificate` | Set `SSL_CA_FILE` to your CA certificate | +| `certificate verify failed` | Ensure `SSL_CA_FILE` points to the correct CA | +| `certificate has expired` | Renew the server's SSL certificate | +| `unknown CA` | Add the issuing CA to `SSL_CA_FILE` | ### Rate limiting errors (429) diff --git a/test/models/concerns/ssl_configurable_test.rb b/test/models/concerns/ssl_configurable_test.rb new file mode 100644 index 000000000..89d6cfa2b --- /dev/null +++ b/test/models/concerns/ssl_configurable_test.rb @@ -0,0 +1,165 @@ +require "test_helper" + +class SslConfigurableTest < ActiveSupport::TestCase + # Create a simple test host that extends SslConfigurable, mirroring how + # providers use it in the actual codebase. + class SslTestHost + extend SslConfigurable + end + + setup do + # Snapshot original config so we can restore it in teardown + @original_verify = Rails.configuration.x.ssl.verify + @original_ca_file = Rails.configuration.x.ssl.ca_file + @original_debug = Rails.configuration.x.ssl.debug + end + + teardown do + Rails.configuration.x.ssl.verify = @original_verify + Rails.configuration.x.ssl.ca_file = @original_ca_file + Rails.configuration.x.ssl.debug = @original_debug + end + + # -- ssl_verify? -- + + test "ssl_verify? returns true when verify is nil (default)" do + Rails.configuration.x.ssl.verify = nil + assert SslTestHost.ssl_verify? + end + + test "ssl_verify? returns true when verify is true" do + Rails.configuration.x.ssl.verify = true + assert SslTestHost.ssl_verify? + end + + test "ssl_verify? returns false when verify is explicitly false" do + Rails.configuration.x.ssl.verify = false + refute SslTestHost.ssl_verify? + end + + # -- ssl_ca_file -- + + test "ssl_ca_file returns nil when no CA file is configured" do + Rails.configuration.x.ssl.ca_file = nil + assert_nil SslTestHost.ssl_ca_file + end + + test "ssl_ca_file returns the configured path" do + Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt" + assert_equal "/certs/my-ca.crt", SslTestHost.ssl_ca_file + end + + # -- ssl_debug? -- + + test "ssl_debug? returns false when debug is nil" do + Rails.configuration.x.ssl.debug = nil + refute SslTestHost.ssl_debug? + end + + test "ssl_debug? returns true when debug is true" do + Rails.configuration.x.ssl.debug = true + assert SslTestHost.ssl_debug? + end + + # -- faraday_ssl_options -- + + test "faraday_ssl_options returns verify true with no CA file by default" do + Rails.configuration.x.ssl.verify = true + Rails.configuration.x.ssl.ca_file = nil + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.faraday_ssl_options + + assert_equal true, options[:verify] + assert_nil options[:ca_file] + end + + test "faraday_ssl_options includes ca_file when configured" do + Rails.configuration.x.ssl.verify = true + Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt" + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.faraday_ssl_options + + assert_equal true, options[:verify] + assert_equal "/certs/my-ca.crt", options[:ca_file] + end + + test "faraday_ssl_options returns verify false when verification disabled" do + Rails.configuration.x.ssl.verify = false + Rails.configuration.x.ssl.ca_file = nil + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.faraday_ssl_options + + assert_equal false, options[:verify] + end + + test "faraday_ssl_options includes both verify false and ca_file when both configured" do + Rails.configuration.x.ssl.verify = false + Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt" + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.faraday_ssl_options + + assert_equal false, options[:verify] + assert_equal "/certs/my-ca.crt", options[:ca_file] + end + + # -- httparty_ssl_options -- + + test "httparty_ssl_options returns verify true with no CA file by default" do + Rails.configuration.x.ssl.verify = true + Rails.configuration.x.ssl.ca_file = nil + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.httparty_ssl_options + + assert_equal true, options[:verify] + assert_nil options[:ssl_ca_file] + end + + test "httparty_ssl_options includes ssl_ca_file when configured" do + Rails.configuration.x.ssl.verify = true + Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt" + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.httparty_ssl_options + + assert_equal true, options[:verify] + assert_equal "/certs/my-ca.crt", options[:ssl_ca_file] + end + + test "httparty_ssl_options returns verify false when verification disabled" do + Rails.configuration.x.ssl.verify = false + Rails.configuration.x.ssl.ca_file = nil + Rails.configuration.x.ssl.debug = false + + options = SslTestHost.httparty_ssl_options + + assert_equal false, options[:verify] + end + + # -- net_http_verify_mode -- + + test "net_http_verify_mode returns VERIFY_PEER when verification enabled" do + Rails.configuration.x.ssl.verify = true + Rails.configuration.x.ssl.debug = false + + assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode + end + + test "net_http_verify_mode returns VERIFY_NONE when verification disabled" do + Rails.configuration.x.ssl.verify = false + Rails.configuration.x.ssl.debug = false + + assert_equal OpenSSL::SSL::VERIFY_NONE, SslTestHost.net_http_verify_mode + end + + test "net_http_verify_mode returns VERIFY_PEER when verify is nil" do + Rails.configuration.x.ssl.verify = nil + Rails.configuration.x.ssl.debug = false + + assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode + end +end diff --git a/test/models/eval/langfuse_client_test.rb b/test/models/eval/langfuse_client_test.rb new file mode 100644 index 000000000..023e2608c --- /dev/null +++ b/test/models/eval/langfuse_client_test.rb @@ -0,0 +1,66 @@ +require "test_helper" +require "ostruct" + +class Eval::LangfuseClientTest < ActiveSupport::TestCase + # -- CRL error list -- + + test "crl_errors includes standard CRL error codes" do + errors = Eval::Langfuse::Client.crl_errors + + assert_includes errors, OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL + assert_includes errors, OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED + assert_includes errors, OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID + end + + test "crl_errors is frozen" do + assert Eval::Langfuse::Client.crl_errors.frozen? + end + + # -- CRL verify callback behavior -- + # The callback should bypass only CRL-specific errors while preserving the + # original verification result for all other error types. + + test "CRL callback returns true for CRL-unavailable errors" do + crl_error_codes = Eval::Langfuse::Client.crl_errors + store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL) + + callback = build_crl_callback(crl_error_codes) + + assert callback.call(false, store_ctx), "CRL errors should be bypassed even when preverify_ok is false" + end + + test "CRL callback preserves preverify_ok for non-CRL errors" do + crl_error_codes = Eval::Langfuse::Client.crl_errors + # V_OK (0) is not a CRL error + store_ctx = OpenStruct.new(error: 0) + + callback = build_crl_callback(crl_error_codes) + + assert callback.call(true, store_ctx), "Non-CRL errors with preverify_ok=true should pass" + refute callback.call(false, store_ctx), "Non-CRL errors with preverify_ok=false should fail" + end + + test "CRL callback rejects cert errors that are not CRL-related" do + crl_error_codes = Eval::Langfuse::Client.crl_errors + # V_ERR_CERT_HAS_EXPIRED is a real cert error, not CRL + store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED) + + callback = build_crl_callback(crl_error_codes) + + refute callback.call(false, store_ctx), "Non-CRL cert errors should not be bypassed" + end + + private + + # Reconstructs the same lambda used in Eval::Langfuse::Client#execute_request + # for isolated testing without needing a real Net::HTTP connection. + def build_crl_callback(crl_error_codes) + ->(preverify_ok, store_ctx) { + if crl_error_codes.include?(store_ctx.error) + true + else + preverify_ok + end + } + end +end diff --git a/test/models/provider/simplefin_test.rb b/test/models/provider/simplefin_test.rb index e0cbaee23..a795b7342 100644 --- a/test/models/provider/simplefin_test.rb +++ b/test/models/provider/simplefin_test.rb @@ -10,7 +10,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase # First call raises timeout, second call succeeds mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') - HTTParty.expects(:get) + Provider::Simplefin.expects(:get) .times(2) .raises(Net::ReadTimeout.new("Connection timed out")) .then.returns(mock_response) @@ -25,7 +25,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase test "retries on Net::OpenTimeout and succeeds on retry" do mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') - HTTParty.expects(:get) + Provider::Simplefin.expects(:get) .times(2) .raises(Net::OpenTimeout.new("Connection timed out")) .then.returns(mock_response) @@ -39,7 +39,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase test "retries on SocketError and succeeds on retry" do mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') - HTTParty.expects(:get) + Provider::Simplefin.expects(:get) .times(2) .raises(SocketError.new("Failed to open TCP connection")) .then.returns(mock_response) @@ -51,7 +51,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase end test "raises SimplefinError after max retries exceeded" do - HTTParty.expects(:get) + Provider::Simplefin.expects(:get) .times(4) # Initial + 3 retries .raises(Net::ReadTimeout.new("Connection timed out")) @@ -66,7 +66,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase end test "does not retry on non-retryable errors" do - HTTParty.expects(:get) + Provider::Simplefin.expects(:get) .times(1) .raises(ArgumentError.new("Invalid argument")) @@ -80,7 +80,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase test "handles HTTP 429 rate limit response" do mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded") - HTTParty.expects(:get).returns(mock_response) + Provider::Simplefin.expects(:get).returns(mock_response) error = assert_raises(Provider::Simplefin::SimplefinError) do @provider.get_accounts(@access_url) @@ -93,7 +93,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase test "handles HTTP 500 server error response" do mock_response = OpenStruct.new(code: 500, body: "Internal Server Error") - HTTParty.expects(:get).returns(mock_response) + Provider::Simplefin.expects(:get).returns(mock_response) error = assert_raises(Provider::Simplefin::SimplefinError) do @provider.get_accounts(@access_url) @@ -106,7 +106,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase setup_token = Base64.encode64("https://example.com/claim") mock_response = OpenStruct.new(code: 200, body: "https://example.com/access") - HTTParty.expects(:post) + Provider::Simplefin.expects(:post) .times(2) .raises(Net::ReadTimeout.new("Connection timed out")) .then.returns(mock_response)