Skip to content

Commit ea0aa72

Browse files
committed
fixes for host authorization
1 parent ca89fbf commit ea0aa72

File tree

8 files changed

+164
-55
lines changed

8 files changed

+164
-55
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
2. **ALWAYS run `task ci` before claiming completion**
77
3. **NO EXCEPTIONS to the above rules - features are NOT complete until all checks pass**
88
4. **This rule must ALWAYS be followed no matter what**
9+
5. **`additional_data` is ONLY for unknown and dynamic fields that a user or integration might send. IF YOU KNOW THE NAME OF A FIELD, IT GOES IN THE SCHEMA. NEVER use `additional_data` for known fields.**
910

1011
## Commands
1112

lib/log_struct/enums/log_field.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class LogField < T::Enum
4747
# Security-specific fields
4848
BlockedHost = new(:blocked_host)
4949
BlockedHosts = new(:blocked_hosts)
50+
AllowedHosts = new(:allowed_hosts)
51+
AllowIpHosts = new(:allow_ip_hosts)
5052
ClientIp = new(:client_ip)
5153
XForwardedFor = new(:x_forwarded_for)
5254

lib/log_struct/integrations/host_authorization.rb

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require "action_dispatch/middleware/host_authorization"
55
require_relative "../enums/event"
6+
require_relative "../log/security/blocked_host"
67

78
module LogStruct
89
module Integrations
@@ -34,56 +35,46 @@ def self.setup(config)
3435
return nil unless config.enabled
3536
return nil unless config.integrations.enable_host_authorization
3637

37-
# In test environment, ensure HostAuthorization does not block requests
38-
# from the default integration test hosts. Allow all hosts explicitly.
39-
if ::Rails.env.test? && ::Rails.application.config.respond_to?(:hosts)
40-
begin
41-
::Rails.application.config.hosts << /.*\z/
42-
rescue
43-
# best-effort; ignore if hosts not configurable
44-
end
45-
# Additionally, exclude all requests from HostAuthorization in test
46-
begin
47-
::Rails.application.config.host_authorization ||= {}
48-
::Rails.application.config.host_authorization[:exclude] = ->(_request) { true }
49-
rescue
50-
# best-effort
51-
end
52-
end
53-
5438
# Define the response app as a separate variable to fix block alignment
5539
response_app = lambda do |env|
5640
request = ::ActionDispatch::Request.new(env)
5741
# Include the blocked hosts app configuration in the log entry
5842
# This can be helpful later when reviewing logs.
5943
blocked_hosts = env["action_dispatch.blocked_hosts"]
6044

61-
# Create a security error to be handled
62-
blocked_host_error = ::ActionController::BadRequest.new(
63-
"Blocked host detected: #{request.host}"
64-
)
45+
# Build allowed_hosts array
46+
allowed_hosts_array = T.let(nil, T.nilable(T::Array[String]))
47+
if blocked_hosts.respond_to?(:allowed_hosts)
48+
allowed_hosts_array = blocked_hosts.allowed_hosts
49+
end
50+
51+
# Get allow_ip_hosts value
52+
allow_ip_hosts_value = T.let(nil, T.nilable(T::Boolean))
53+
if blocked_hosts.respond_to?(:allow_ip_hosts)
54+
allow_ip_hosts_value = blocked_hosts.allow_ip_hosts
55+
end
6556

66-
# Create request context hash
67-
context = {
57+
# Create structured log entry for blocked host
58+
log_entry = LogStruct::Log::Security::BlockedHost.new(
59+
message: "Blocked host detected: #{request.host}",
6860
blocked_host: request.host,
69-
client_ip: request.ip,
70-
x_forwarded_for: request.x_forwarded_for,
71-
http_method: request.method,
7261
path: request.path,
62+
http_method: request.method,
63+
source_ip: request.ip,
7364
user_agent: request.user_agent,
74-
allowed_hosts: blocked_hosts.allowed_hosts,
75-
allow_ip_hosts: blocked_hosts.allow_ip_hosts
76-
}
77-
78-
# Handle error according to configured mode (log, report, raise)
79-
LogStruct.handle_exception(
80-
blocked_host_error,
81-
source: Source::Security,
82-
context: context
65+
referer: request.referer,
66+
request_id: request.request_id,
67+
x_forwarded_for: request.x_forwarded_for,
68+
allowed_hosts: allowed_hosts_array&.empty? ? nil : allowed_hosts_array,
69+
allow_ip_hosts: allow_ip_hosts_value
8370
)
8471

72+
# Log the blocked host
73+
LogStruct.warn(log_entry)
74+
8575
# Use pre-defined headers and response if we are only logging or reporting
86-
[FORBIDDEN_STATUS, RESPONSE_HEADERS, [RESPONSE_HTML]]
76+
# Dup the headers so they can be modified by downstream middleware
77+
[FORBIDDEN_STATUS, RESPONSE_HEADERS.dup, [RESPONSE_HTML]]
8778
end
8879

8980
# Merge our response_app into existing host_authorization config to preserve excludes

lib/log_struct/log/security/blocked_host.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class BlockedHost < T::Struct
4040
const :message, T.nilable(String), default: nil
4141
const :blocked_host, T.nilable(String), default: nil
4242
const :blocked_hosts, T.nilable(T::Array[String]), default: nil
43+
const :x_forwarded_for, T.nilable(String), default: nil
44+
const :allowed_hosts, T.nilable(T::Array[String]), default: nil
45+
const :allow_ip_hosts, T.nilable(T::Boolean), default: nil
4346

4447
# Additional data
4548
include LogStruct::Log::Interfaces::AdditionalDataField
@@ -66,6 +69,9 @@ def to_h
6669
h[LogField::Message] = message unless message.nil?
6770
h[LogField::BlockedHost] = blocked_host unless blocked_host.nil?
6871
h[LogField::BlockedHosts] = blocked_hosts unless blocked_hosts.nil?
72+
h[LogField::XForwardedFor] = x_forwarded_for unless x_forwarded_for.nil?
73+
h[LogField::AllowedHosts] = allowed_hosts unless allowed_hosts.nil?
74+
h[LogField::AllowIpHosts] = allow_ip_hosts unless allow_ip_hosts.nil?
6975
h
7076
end
7177
end

lib/log_struct/railtie.rb

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,6 @@
1010
module LogStruct
1111
# Railtie to integrate with Rails
1212
class Railtie < ::Rails::Railtie
13-
# Ensure test hosts are allowed early enough for middleware build
14-
initializer "logstruct.allow_test_hosts", before: :build_middleware_stack do |app|
15-
if ::Rails.env.test? && app.config.respond_to?(:hosts)
16-
begin
17-
app.config.hosts << /.*\z/
18-
rescue
19-
# best-effort
20-
end
21-
begin
22-
app.config.middleware.delete(::ActionDispatch::HostAuthorization)
23-
rescue
24-
# best-effort
25-
end
26-
end
27-
end
28-
29-
# After ActionDispatch is configured, remove HostAuthorization in test to prevent 403s
30-
# (No late deletion needed; handled above before middleware stack is built)
31-
3213
# Configure early, right after logger initialization
3314
initializer "logstruct.configure_logger", after: :initialize_logger do |app|
3415
next unless LogStruct.enabled?
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
6+
class HostAuthorizationTest < ActionDispatch::IntegrationTest
7+
def setup
8+
# Capture JSON output via a dedicated SemanticLogger appender
9+
@io = StringIO.new
10+
::SemanticLogger.clear_appenders!
11+
# Use synchronous appender to avoid timing issues in tests
12+
::SemanticLogger.add_appender(io: @io, formatter: LogStruct::SemanticLogger::Formatter.new, async: false)
13+
end
14+
15+
def test_blocked_host_is_logged_with_logstruct
16+
# Make a request with a blocked host
17+
host! "blocked-host.example.com"
18+
get "/health"
19+
20+
# Should return 403 Forbidden
21+
assert_response :forbidden
22+
23+
# Ensure all logs are flushed from buffers
24+
::SemanticLogger.flush
25+
26+
# Read all logged lines
27+
@io.rewind
28+
lines = @io.read.to_s.split("\n").map(&:strip).reject(&:empty?)
29+
30+
# Parse JSON logs
31+
parsed_logs = lines.filter_map { |l|
32+
begin
33+
JSON.parse(l)
34+
rescue
35+
nil
36+
end
37+
}
38+
39+
# Find blocked host logs
40+
blocked_host_logs = parsed_logs.select { |log| log["evt"] == "blocked_host" }
41+
42+
assert_equal 1, blocked_host_logs.size, "Expected exactly one blocked host log entry"
43+
44+
log_entry = blocked_host_logs.first
45+
46+
# Verify the log entry has the correct structure
47+
assert_equal "security", log_entry["src"]
48+
assert_equal "blocked_host", log_entry["evt"]
49+
assert_equal "blocked-host.example.com", log_entry["blocked_host"]
50+
assert_equal "/health", log_entry["path"]
51+
assert_equal "GET", log_entry["method"]
52+
end
53+
54+
def test_allowed_host_is_not_blocked
55+
# Make a request with an allowed host (.localhost is allowed by default)
56+
host! "www.localhost"
57+
get "/health"
58+
59+
# Should return 200 OK
60+
assert_response :success
61+
62+
# Ensure all logs are flushed from buffers
63+
::SemanticLogger.flush
64+
65+
# Read all logged lines
66+
@io.rewind
67+
lines = @io.read.to_s.split("\n").map(&:strip).reject(&:empty?)
68+
69+
# Parse JSON logs
70+
parsed_logs = lines.filter_map { |l|
71+
begin
72+
JSON.parse(l)
73+
rescue
74+
nil
75+
end
76+
}
77+
78+
# Find blocked host logs
79+
blocked_host_logs = parsed_logs.select { |log| log["evt"] == "blocked_host" }
80+
81+
assert_equal 0, blocked_host_logs.size, "Should not log blocked host for allowed hosts"
82+
end
83+
84+
def test_blocked_host_log_can_be_serialized
85+
host! "malicious.example.com"
86+
get "/health"
87+
88+
assert_response :forbidden
89+
90+
# Ensure all logs are flushed from buffers
91+
::SemanticLogger.flush
92+
93+
# Read all logged lines
94+
@io.rewind
95+
lines = @io.read.to_s.split("\n").map(&:strip).reject(&:empty?)
96+
97+
# Parse JSON logs
98+
parsed_logs = lines.filter_map { |l|
99+
begin
100+
JSON.parse(l)
101+
rescue
102+
nil
103+
end
104+
}
105+
106+
# Find blocked host logs
107+
blocked_host_logs = parsed_logs.select { |log| log["evt"] == "blocked_host" }
108+
109+
assert_equal 1, blocked_host_logs.size
110+
111+
log_entry = blocked_host_logs.first
112+
113+
# Verify it's a properly serialized hash
114+
assert_kind_of Hash, log_entry
115+
116+
# Verify key fields are in serialized output
117+
assert_equal "security", log_entry["src"]
118+
assert_equal "blocked_host", log_entry["evt"]
119+
assert_equal "malicious.example.com", log_entry["blocked_host"]
120+
assert_equal "/health", log_entry["path"]
121+
assert_equal "GET", log_entry["method"]
122+
end
123+
end

schemas/log_sources/security.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ events:
2626
Message: String
2727
BlockedHost: String
2828
BlockedHosts: 'T::Array[String]'
29+
XForwardedFor: String
30+
AllowedHosts: 'T::Array[String]'
31+
AllowIpHosts: T::Boolean

schemas/meta/log-fields.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"Adapter",
1212
"Address",
1313
"AhoyEvent",
14+
"AllowIpHosts",
15+
"AllowedHosts",
1416
"Arguments",
1517
"AttachmentCount",
1618
"Attempt",

0 commit comments

Comments
 (0)