diff --git a/lib/fluent/plugin/output.rb b/lib/fluent/plugin/output.rb index 6ee7c6c070..abaa29fa72 100644 --- a/lib/fluent/plugin/output.rb +++ b/lib/fluent/plugin/output.rb @@ -44,6 +44,8 @@ class Output < Base CHUNK_KEY_PLACEHOLDER_PATTERN = /\$\{([-_.@$a-zA-Z0-9]+)\}/ CHUNK_TAG_PLACEHOLDER_PATTERN = /\$\{(tag(?:\[-?\d+\])?)\}/ CHUNK_ID_PLACEHOLDER_PATTERN = /\$\{chunk_id\}/ + INVALID_PATH_COMPONENT_PATTERN = %r{\.\.[/\\]|^[/\\]} + PARENT_DIRECTORY_PATTERN = %r{\.\.[/\\]} CHUNKING_FIELD_WARN_NUM = 4 @@ -825,9 +827,17 @@ def extract_placeholders(str, chunk) # ${tag}, ${tag[0]}, ${tag[1]}, ... , ${tag[-2]}, ${tag[-1]} if @chunk_key_tag if str.include?('${tag}') + if metadata.tag.match?(INVALID_PATH_COMPONENT_PATTERN) + raise Fluent::UnrecoverableError, "Invalid path component detected in tag: #{metadata.tag}" + end + rvalue = rvalue.gsub('${tag}', metadata.tag) end if CHUNK_TAG_PLACEHOLDER_PATTERN.match?(str) + if metadata.tag.match?(INVALID_PATH_COMPONENT_PATTERN) + raise Fluent::UnrecoverableError, "Invalid path component detected in tag: #{metadata.tag}" + end + hash = {} tag_parts = metadata.tag.split('.') tag_parts.each_with_index do |part, i| @@ -858,10 +868,21 @@ def extract_placeholders(str, chunk) end rvalue = rvalue.gsub(CHUNK_KEY_PLACEHOLDER_PATTERN) do |matched| - hash.fetch(matched) do + replace = hash.fetch(matched) do log.warn "chunk key placeholder '#{matched[2..-2]}' not replaced. template:#{str}" '' end + if replace.to_s.match?(INVALID_PATH_COMPONENT_PATTERN) + raise Fluent::UnrecoverableError, "Invalid path component detected in #{matched}: #{replace}" + end + + replace + end + # Check if the number of parent directory components (../) has increased due to variable substitution + if rvalue.match?(PARENT_DIRECTORY_PATTERN) + if rvalue.scan(PARENT_DIRECTORY_PATTERN).size > str.scan(PARENT_DIRECTORY_PATTERN).size + raise Fluent::UnrecoverableError, "Invalid path component detected, replaced to: #{rvalue}" + end end end diff --git a/test/plugin/test_output.rb b/test/plugin/test_output.rb index 8fb1d188cd..50c267ff30 100644 --- a/test/plugin/test_output.rb +++ b/test/plugin/test_output.rb @@ -448,6 +448,129 @@ def waiting(seconds) assert { logs.any? { |log| log.include?("chunk key placeholder 'key2' not replaced. template:#{tmpl}") } } end + sub_test_case 'Path boundary validation for ${tag} placeholder' do + data( + 'normal_dot' => 'app.web', + 'safe_slash' => 'app/web', + 'symbol_mixed' => 'my_tag-123', + 'no_match' => 'app..web/log', + ) + test 'allows valid tags including safe slashes' do |tag| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { key1: "value1" } + m = create_metadata(timekey: t, tag: tag, variables: v) + result = @i.extract_placeholders("/data/${tag}/log", m) + assert_equal "/data/#{tag}/log", result + end + + data( + 'unix_relative' => '../etc/cron.d', + 'windows_relative' => '..\\Windows\\System32', + 'unix_absolute' => '/etc/passwd', + 'windows_absolute' => '\\Windows' + ) + test 'rejects tags containing parent directory relative or absolute root paths' do |tag| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { key1: "value1" } + m = create_metadata(timekey: t, tag: tag, variables: v) + + err = assert_raise(Fluent::UnrecoverableError) do + @i.extract_placeholders("/data/${tag}/log", m) + end + assert_match(/Invalid path component detected in tag/, err.message) + end + + data( + 'unix_relative' => '../etc/cron.d', + 'windows_relative' => '..\\Windows\\System32', + 'unix_absolute' => '/etc/passwd', + 'windows_absolute' => '\\Windows' + ) + test 'rejects tags containing traversal paths even when only parts are used' do |tag| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { key1: "value1" } + m = create_metadata(timekey: t, tag: tag, variables: v) + + err = assert_raise(Fluent::UnrecoverableError) do + @i.extract_placeholders("/data/${tag[0]}/log", m) + end + assert_match(/Invalid path component detected in tag/, err.message) + end + + data( + 'unix_relative' => '../etc/cron.d', + 'windows_relative' => '..\\Windows\\System32', + 'unix_absolute' => '/etc/passwd', + 'windows_absolute' => '\\Windows' + ) + test 'rejects variables containing parent directory relative or absolute root paths' do |var_value| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag,file')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { file: var_value } + m = create_metadata(timekey: t, tag: 'app.web', variables: v) + + err = assert_raise(Fluent::UnrecoverableError) do + @i.extract_placeholders("/data/${tag}/${file}", m) + end + assert_match(/Invalid path component detected in \$\{file\}/, err.message) + end + + data( + 'combined ../' => { dot: '.', dotslash: './', file: 'safe.log' }, + 'combined ..\\' => { dot: '.', dotslash: '.\\', file: 'safe.log' }, + ) + test 'rejects combined variables containing dot and slash combination' do |var_value| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag,dot,dotslash,file')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = var_value + m = create_metadata(timekey: t, tag: 'app.web', variables: v) + + err = assert_raise(Fluent::UnrecoverableError) do + @i.extract_placeholders("/data/${dot}${dotslash}${file}", m) + end + assert_match(/Invalid path component detected, replaced to/, err.message) + end + + test 'passes safely when placeholder expansion does NOT introduce additional ../' do + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'val1,val2,val3')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { val1: "safe", val2: "." } + m = create_metadata(timekey: t, variables: v) + + result = @i.extract_placeholders("../data/${val1}${val2}log", m) + assert_equal "../data/safe.log", result + end + + data( + 'normal_string' => 'app.log', + 'safe_slash' => 'logs/app.log', + 'integer_val' => 80, + 'boolean_val' => true + ) + test 'allows valid variables including safe strings and non-string types' do |var_value| + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag,file')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { file: var_value } + m = create_metadata(timekey: t, tag: 'app.web', variables: v) + + result = @i.extract_placeholders("/data/${tag}/${file}", m) + assert_equal "/data/app.web/#{var_value}", result + end + + test 'skips validation if ${tag} placeholder is not used' do + @i.configure(config_element('ROOT', '', {}, [config_element('buffer', 'tag')])) + t = event_time('2016-04-11 20:30:00 +0900') + v = { key1: "value1" } + m = create_metadata(timekey: t, tag: '../etc/passwd', variables: v) + + result = @i.extract_placeholders("/data/static/log", m) + assert_equal "/data/static/log", result + end + end + sub_test_case '#placeholder_validators' do test 'returns validators for time, tag and keys when a template has placeholders even if plugin is not configured with these keys' do @i.configure(config_element('ROOT', '', {}, [config_element('buffer', '')]))