Skip to content

Commit c6a01ea

Browse files
authored
Backport(v1.19) out_http: add strict host validation for dynamic endpoints (#5394)
**Which issue(s) this PR fixes**: Fixes # **What this PR does / why we need it**: This PR introduces strict host validation to ensure predictable and safe routing when using dynamic endpoints. ### Changes * Added `allowed_hosts` parameter (default: `[]`). * An array configuration to explicitly define permitted hostnames for dynamic endpoints. * Unaffected when use static endpoints, or endpoints where placeholders do not alter the host. **Docs Changes**: **Release Note**: * out_http: add strict host validation for dynamic endpoints Signed-off-by: Shizuo Fujita <fujita@clear-code.com>
1 parent f5f2b7c commit c6a01ea

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

lib/fluent/plugin/out_http.rb

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require 'net/http'
1818
require 'uri'
1919
require 'openssl'
20+
require 'securerandom'
2021
require 'fluent/tls'
2122
require 'fluent/plugin/output'
2223
require 'fluent/plugin_helper/socket'
@@ -58,6 +59,9 @@ class RetryableResponse < StandardError; end
5859
desc 'Compress HTTP request body'
5960
config_param :compress, :enum, list: [:text, :gzip], default: :text
6061

62+
desc 'Allowed hosts list for dynamic endpoints'
63+
config_param :allowed_hosts, :array, default: []
64+
6165
desc 'The connection open timeout in seconds'
6266
config_param :open_timeout, :integer, default: nil
6367
desc 'The read timeout in seconds'
@@ -106,6 +110,11 @@ class RetryableResponse < StandardError; end
106110
config_param :aws_role_arn, :string, default: nil
107111
end
108112

113+
# To prevent URI::InvalidURIError, we replace Fluentd placeholders with a dummy string.
114+
# We use the ".invalid" TLD (RFC 2606) to ensure it is RFC-compliant for URI parsing,
115+
# while guaranteeing it will never conflict with a real-world hostname.
116+
REPLACED_ENDPOINT_PLACEHOLDER = "#{SecureRandom.uuid}.invalid".freeze
117+
109118
def connection_cache_id_thread_key
110119
"#{plugin_id}_connection_cache_id"
111120
end
@@ -146,6 +155,15 @@ def configure(conf)
146155
@retryable_response_codes = [503]
147156
end
148157

158+
begin
159+
# Replace all Fluentd placeholder syntaxes (${...} or %{...})
160+
endpoint = @endpoint.gsub(%r([$%]{[^}]+}), REPLACED_ENDPOINT_PLACEHOLDER)
161+
# If @endpoint has placeholder as host name, then, @endpoint_host == REPLACED_ENDPOINT_PLACEHOLDER
162+
@endpoint_host = URI.parse(endpoint).host
163+
rescue URI::InvalidURIError => e
164+
raise Fluent::ConfigError, "Invalid endpoint URI: #{@endpoint} (#{e.message})"
165+
end
166+
149167
@http_opt = setup_http_option
150168
@proxy_uri = URI.parse(@proxy) if @proxy
151169
@formatter = formatter_create
@@ -278,7 +296,19 @@ def setup_http_option
278296

279297
def parse_endpoint(chunk)
280298
endpoint = extract_placeholders(@endpoint, chunk)
281-
URI.parse(endpoint)
299+
uri = URI.parse(endpoint)
300+
301+
if @endpoint_host != uri.host
302+
if @allowed_hosts.empty?
303+
raise Fluent::UnrecoverableError, "allowed_hosts is strictly required when using placeholders in the endpoint host"
304+
end
305+
306+
unless @allowed_hosts.include?(uri.host)
307+
raise Fluent::UnrecoverableError, "Not allowed host: #{uri.host}"
308+
end
309+
end
310+
311+
uri
282312
end
283313

284314
def set_headers(req, uri, chunk)

test/plugin/test_out_http.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,12 @@ def test_configure_content_type_json_array(content_type)
228228
assert_equal content_type, d.instance.content_type
229229
end
230230

231+
def test_configure_allowed_hosts
232+
d = create_driver(config + 'allowed_hosts ["example.com"]')
233+
234+
assert_equal ["example.com"], d.instance.allowed_hosts
235+
end
236+
231237
data('PUT' => 'put', 'POST' => 'post')
232238
def test_write_with_method(method)
233239
d = create_driver(config + "http_method #{method}")
@@ -600,4 +606,61 @@ def test_connection_recreation
600606
assert_not_empty result.headers
601607
end
602608
end
609+
610+
sub_test_case 'dynamic endpoint host validation in parse_endpoint' do
611+
setup do
612+
@chunk = Object.new
613+
end
614+
615+
test 'allows request when host does not change (e.g., placeholder only in path)' do
616+
plugin = create_driver(%(
617+
endpoint http://api.example.com/logs/${tag}
618+
)).instance
619+
620+
stub(plugin).extract_placeholders { 'http://api.example.com/logs/my.custom.tag' }
621+
622+
uri = plugin.send(:parse_endpoint, @chunk)
623+
assert_equal 'api.example.com', uri.host
624+
assert_equal '/logs/my.custom.tag', uri.path
625+
end
626+
627+
test 'allows request when host changes and new host is in allowed_hosts' do
628+
plugin = create_driver(%(
629+
endpoint http://${tag}:8080/api
630+
allowed_hosts ["known-host.example.com", "another-host.example.com"]
631+
)).instance
632+
633+
stub(plugin).extract_placeholders { 'http://known-host.example.com:8080/api' }
634+
635+
uri = plugin.send(:parse_endpoint, @chunk)
636+
assert_equal 'known-host.example.com', uri.host
637+
end
638+
639+
test 'raises UnrecoverableError when host changes and allowed_hosts is empty' do
640+
plugin = create_driver(%(
641+
endpoint http://${tag}:8080/api
642+
)).instance
643+
644+
stub(plugin).extract_placeholders { 'http://unknown-host.example.com:8080/api' }
645+
646+
err = assert_raise(Fluent::UnrecoverableError) do
647+
plugin.send(:parse_endpoint, @chunk)
648+
end
649+
assert_match(/allowed_hosts is strictly required when using placeholders in the endpoint host/, err.message)
650+
end
651+
652+
test 'raises UnrecoverableError when host changes and it is not in allowed_hosts' do
653+
plugin = create_driver(%(
654+
endpoint http://${tag}:8080/api
655+
allowed_hosts ["known-host.example.com"]
656+
)).instance
657+
658+
stub(plugin).extract_placeholders { 'http://unknown-host.example.com:8080/api' }
659+
660+
err = assert_raise(Fluent::UnrecoverableError) do
661+
plugin.send(:parse_endpoint, @chunk)
662+
end
663+
assert_match(/Not allowed host: unknown-host\.example\.com/, err.message)
664+
end
665+
end
603666
end

0 commit comments

Comments
 (0)