Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
68 changes: 68 additions & 0 deletions app/web/security/log_sanitizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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/)([^/?]+?)(\.(?:json|xml|rss))?\z}

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

path.to_s.gsub(FEED_TOKEN_ROUTE, '\1[REDACTED]\3')
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 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
51 changes: 15 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,9 @@ 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}")
sanitized_data = LogSanitizer.sanitize_details(data)
Kernel.warn("Structured logging fallback: #{error.class}: #{error.message}")
Kernel.warn("component=security_logger security_event=#{event_type} details=#{sanitized_data}")
end
end
end
Expand Down
101 changes: 101 additions & 0 deletions app/web/telemetry/app_logger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# 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)
message_string = message.to_s
return parsed_json(message_string) if json_like?(message_string)

parse_logfmt(message_string) || { message: message_string }
end

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

# @param message [String]
# @return [Boolean]
def json_like?(message)
stripped = message.lstrip
stripped.start_with?('{', '[')
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