Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/web/boot/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class << self
def call!
validate_environment!
configure_request_service!
configure_runtime_logging!
end

private
Expand All @@ -28,6 +29,13 @@ def validate_environment!
def configure_request_service!
nil
end

# @return [void]
def configure_runtime_logging!
return unless defined?(Rack::Timeout::Logger)

Rack::Timeout::Logger.logger = AppLogger.logger
end
end
end
end
Expand Down
49 changes: 40 additions & 9 deletions app/web/config/environment_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@ def auto_source_enabled?

def set_development_key
ENV['HTML2RSS_SECRET_KEY'] = 'development-default-key-not-for-production'
puts '⚠️ WARNING: Using default secret key for development/testing only!'
puts ' Set HTML2RSS_SECRET_KEY environment variable for production use.'
log_development_default_secret_key_warning
warn_lines(
'WARNING: Using default secret key for development/testing only!',
'Set HTML2RSS_SECRET_KEY environment variable for production use.'
)
nil
end

def show_production_error
puts production_error_message
SecurityLogger.log_config_validation_failure('secret_key', 'Missing required secret key')
warn_lines(*production_error_message.lines(chomp: true))
exit 1
end

Expand All @@ -79,9 +84,11 @@ def validate_secret_key!
return unless secret == 'your-generated-secret-key-here' || secret.length < 32

SecurityLogger.log_config_validation_failure('secret_key', 'Invalid or weak secret key')
puts '❌ CRITICAL: Invalid secret key for production deployment!'
puts ' Secret key must be at least 32 characters and not the default placeholder.'
puts ' Generate a secure key: openssl rand -hex 32'
warn_lines(
'CRITICAL: Invalid secret key for production deployment!',
'Secret key must be at least 32 characters and not the default placeholder.',
'Generate a secure key: openssl rand -hex 32'
)
exit 1
end

Expand All @@ -90,11 +97,35 @@ def validate_account_configuration!
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
return unless weak_tokens.any?

handle_weak_account_tokens!(weak_tokens)
end

# @param lines [Array<String>]
# @return [void]
def warn_lines(*lines)
lines.each { |line| Kernel.warn(line) }
nil
end

# @return [void]
def log_development_default_secret_key_warning
SecurityLogger.log_config_validation_failure(
'secret_key',
'Using development default secret key',
severity: :warn
)
end

# @param weak_tokens [Array<Hash{Symbol=>String}>]
# @return [void]
def handle_weak_account_tokens!(weak_tokens)
weak_usernames = weak_tokens.map { |acc| acc[:username] }.join(', ')
SecurityLogger.log_config_validation_failure('account_tokens', "Weak tokens for users: #{weak_usernames}")
puts '❌ CRITICAL: Weak authentication tokens detected in production!'
puts ' All tokens must be at least 16 characters long.'
puts " Weak tokens found for users: #{weak_usernames}"
warn_lines(
'CRITICAL: Weak authentication tokens detected in production!',
'All tokens must be at least 16 characters long.',
"Weak tokens found for users: #{weak_usernames}"
)
exit 1
end
end
Expand Down
3 changes: 2 additions & 1 deletion app/web/request/request_context_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'rack/request'
require 'securerandom'
require 'time'
require_relative '../security/log_sanitizer'

module Html2rss
module Web
Expand Down Expand Up @@ -57,7 +58,7 @@ def build_context(request)
path = request.path_info.to_s
RequestContext::Context.new(
request_id: request_id_for(request),
path: path,
path: LogSanitizer.sanitize_path(path),
http_method: request.request_method.to_s.upcase,
route_group: route_group_for(path),
Comment thread
gildesmarais marked this conversation as resolved.
actor: nil,
Expand Down
82 changes: 82 additions & 0 deletions app/web/security/log_sanitizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

require 'digest'
require 'html2rss/url'

module Html2rss
module Web
##
# Sanitizes request paths and log payloads before they are emitted.
module LogSanitizer
FEED_TOKEN_ROUTE = %r{\A(/api/v1/feeds/)([^/?]+)\z}

class << self
# @param path [String, nil]
# @return [String, nil]
def sanitize_path(path)
return if path.nil?

path_string = path.to_s
suffix = feed_suffix(path_string)
token_path = suffix ? path_string.delete_suffix(suffix) : path_string

token_path.gsub(FEED_TOKEN_ROUTE, "\\1[REDACTED]#{suffix}")
end
Comment on lines +16 to +20
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitize_path always strips a .json/.xml/.rss suffix before attempting the feed-token replacement. If the path ends with one of those suffixes but does not match the /api/v1/feeds/:token pattern, the method returns the suffix-stripped path, which will corrupt logged paths (e.g., /api/v1/health.json -> /api/v1/health). Consider matching the full feed-token route (including an optional suffix) and only redacting when that match succeeds, otherwise return the original path_string unchanged.

Copilot uses AI. Check for mistakes.

# @param details [Hash]
# @return [Hash]
def sanitize_details(details)
details.each_with_object({}) do |(key, value), sanitized|
sanitized[key] = sanitize_value(key, value)
end
end

private

# @param path [String]
# @return [String, nil]
def feed_suffix(path)
return '.json' if path.end_with?('.json')
return '.xml' if path.end_with?('.xml')
return '.rss' if path.end_with?('.rss')

nil
end

# @param key [Object]
# @param value [Object]
# @return [Object]
def sanitize_value(key, value)
return sanitize_details(value) if value.is_a?(Hash)
return value.map { |entry| sanitize_value(key, entry) } if value.is_a?(Array)
return sanitize_url(value) if url_key?(key)

value
end

# @param key [Object]
# @return [Boolean]
def url_key?(key)
key_name = key.to_s
key_name == 'url' || key_name.end_with?('_url', '_urls')
end

# @param value [Object]
# @return [Hash{Symbol=>Object}, Object]
def sanitize_url(value)
url = value.to_s
return value if url.empty?

normalized_url = Html2rss::Url.for_channel(url)
{
host: normalized_url.host,
scheme: normalized_url.scheme,
hash: Digest::SHA256.hexdigest(url)[0..11]
}.compact
rescue StandardError
{ hash: Digest::SHA256.hexdigest(url)[0..11] }
end
end
end
end
end
50 changes: 14 additions & 36 deletions app/web/security/security_logger.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
# frozen_string_literal: true

require 'logger'
require 'json'
require 'digest'
require 'time'
module Html2rss
module Web
##
# Security event logging for html2rss-web
# Provides structured logging for security events to stdout
module SecurityLogger
class << self
# Initialize logger to stdout with structured JSON output
# @return [Logger]
def logger
Thread.current[:security_logger] ||= create_logger
end

# Reset logger (for testing)
# Reset shared logger state for tests.
# @return [void]
def reset_logger!
Thread.current[:security_logger] = nil
AppLogger.reset_logger!
end

##
Expand Down Expand Up @@ -111,12 +102,13 @@ def log_blocked_request(ip, reason, endpoint)
# Log configuration validation failure
# @param component [String] component that failed validation
# @param details [String] validation failure details
# @param severity [Symbol]
# @return [void]
def log_config_validation_failure(component, details)
def log_config_validation_failure(component, details, severity: :error)
log_event('config_validation_failure', {
component: component,
details: details
}, severity: :error)
}, severity: severity)
end

# Log lifecycle events for in-memory config/cache snapshots
Expand All @@ -134,32 +126,18 @@ def log_cache_lifecycle(component, event, details = {})

private

def create_logger
Logger.new($stdout).tap do |log|
log.formatter = proc do |severity, datetime, _progname, msg|
"#{{
timestamp: datetime.iso8601,
level: severity,
service: 'html2rss-web',
**JSON.parse(msg, symbolize_names: true)
}.to_json}\n"
end
end
end

##
# Log a security event
# @param event_type [String] type of security event
# @param data [Hash] event data
def log_event(event_type, data, severity: :warn)
context_data = RequestContext.current_h
payload = {
security_event: event_type,
**context_data,
**data
}.to_json

logger.public_send(severity, payload)
LogEvent.emit(
level: severity,
payload: {
security_event: event_type,
details: data
}
)
rescue StandardError => error
handle_logging_error(error, event_type, data)
end
Expand All @@ -170,8 +148,8 @@ def log_event(event_type, data, severity: :warn)
# @param event_type [String] type of security event
# @param data [Hash] event data
def handle_logging_error(error, event_type, data)
Kernel.warn("Security logging error: #{error.message}")
Kernel.warn("Security event: #{event_type} - #{data}")
Kernel.warn("Structured logging fallback: #{error.class}: #{error.message}")
Kernel.warn("component=security_logger security_event=#{event_type} details=#{data}")
end
end
end
Expand Down
91 changes: 91 additions & 0 deletions app/web/telemetry/app_logger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

require 'json'
require 'logger'
require 'time'

module Html2rss
module Web
##
# Shared structured logger for application and middleware runtime events.
module AppLogger
class << self
# @return [Logger]
def logger
Thread.current[:app_logger] ||= build_logger
end

# @return [void]
def reset_logger!
Thread.current[:app_logger] = nil
end

private

# @return [Logger]
def build_logger
Logger.new($stdout).tap do |log|
log.formatter = method(:format_entry)
end
end

# @param severity [String]
# @param datetime [Time]
# @param _progname [String, nil]
# @param message [String]
# @return [String]
def format_entry(severity, datetime, _progname, message)
"#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n"
end

# @param severity [String]
# @param datetime [Time]
# @return [Hash{Symbol=>Object}]
def base_payload(severity, datetime)
{
timestamp: datetime.iso8601,
level: severity,
service: 'html2rss-web'
}
end

# @param message [Object]
# @return [Hash{Symbol=>Object}]
def normalize_message(message)
parsed_json(message) || parse_logfmt(message.to_s) || { message: message.to_s }
end

# @param message [Object]
# @return [Hash{Symbol=>Object}, nil]
def parsed_json(message)
JSON.parse(message.to_s, symbolize_names: true)
rescue JSON::ParserError, TypeError
nil
end

# @param message [String]
# @return [Hash{Symbol=>Object}, nil]
def parse_logfmt(message)
pairs = message.scan(/([a-zA-Z0-9_.-]+)=("[^"]*"|\S+)/)
return nil if pairs.empty?

pairs.to_h do |key, raw_value|
[key.to_sym, normalize_logfmt_value(raw_value)]
end
end

# @param raw_value [String]
# @return [String, Integer, Float, TrueClass, FalseClass]
def normalize_logfmt_value(raw_value)
value = raw_value.delete_prefix('"').delete_suffix('"')
return true if value == 'true'
return false if value == 'false'
return value.to_i if value.match?(/\A-?\d+\z/)
return value.to_f if value.match?(/\A-?\d+\.\d+\z/)

value
end
end
end
end
end
Loading
Loading