From 7f94b6a426ae34ef9cb66ce8ba683c136e907aaf Mon Sep 17 00:00:00 2001 From: Qingyuan Wang <162230229@nuaa.edu.cn> Date: Sun, 25 Oct 2026 13:45:46 +0000 Subject: [PATCH 1/3] timezone: reject numeric offsets that are not a time of day NUMERIC_PATTERN was documented as [+-]HH:MM / [+-]HHMM / [+-]HH but written as [+-]\d\d(:?\d\d)?\z, so any two digits passed for the hours and any two for the minutes. validate only ever compares against that pattern, so a mistyped zone was accepted everywhere it is configurable -- timezone, timekey_zone and in_tail's path_timezone. Time.zone_offset then adds the fields instead of reading them, so minutes of 60 or more carry into the hours: "timezone +09:60" is a valid-looking spelling of +10:00, and formatted records are an hour off with a %z that contradicts the time it printed. Anything past +/-23:60 leaves the range Time#localtime can apply, so validation passes, the plugin starts, and every record then fails at format time with "ArgumentError: utc_offset out of range". The pattern now requires HH in 00..23 and MM in 00..59, which is exactly the set of numeric offsets Time can apply: enumerating all 40,000 [+-]HH(:?)MM strings, the 5,760 it accepts (24 hours x 60 minutes x 2 spellings x 2 signs) all resolve to the offset they spell, while the 34,240 it rejects were 3,680 silent hour shifts and 30,560 offsets that raise as soon as they are formatted. test/test_timezone.rb covers validate, validate!, utc_offset and formatter for both lists. Signed-off-by: Qingyuan Wang <162230229@nuaa.edu.cn> --- lib/fluent/timezone.rb | 8 +++-- test/test_timezone.rb | 79 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 test/test_timezone.rb diff --git a/lib/fluent/timezone.rb b/lib/fluent/timezone.rb index b0165b5c45..1f92aadcd6 100644 --- a/lib/fluent/timezone.rb +++ b/lib/fluent/timezone.rb @@ -29,8 +29,8 @@ def to_time module Fluent class Timezone - # [+-]HH:MM, [+-]HHMM, [+-]HH - NUMERIC_PATTERN = %r{\A[+-]\d\d(:?\d\d)?\z} + # [+-]HH:MM, [+-]HHMM, [+-]HH, with HH in 00..23 and MM in 00..59 + NUMERIC_PATTERN = %r{\A[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?\z} # Region/Zone, Region/Zone/Zone NAME_PATTERN = %r{\A[^/]+/[^/]+(/[^/]+)?\z} @@ -46,6 +46,10 @@ class Timezone # 4. Region/Zone (e.g. "Asia/Tokyo") # 5. Region/Zone/Zone (e.g. "America/Argentina/Buenos_Aires") # + # In the numeric forms, HH is 00..23 and MM is 00..59: that is exactly the + # range of offsets Time can apply, so a value outside it is either a + # different offset carried into the hours or one that fails when formatted. + # # In the 4th and 5th cases, it is checked whether the specified # timezone exists in the timezone database. # diff --git a/test/test_timezone.rb b/test/test_timezone.rb new file mode 100644 index 0000000000..ee492e1769 --- /dev/null +++ b/test/test_timezone.rb @@ -0,0 +1,79 @@ +# +# Fluentd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative 'helper' +require 'fluent/timezone' + +class TimezoneTest < ::Test::Unit::TestCase + # [+-]HH, [+-]HHMM and [+-]HH:MM with HH in 00..23 and MM in 00..59. + # These are exactly the offsets that Time#localtime accepts: the largest of + # them, +/-23:59, is 86340, one second inside the +-86400 boundary. + VALID = [ + '+00', '-00', '+09', '-09', '+23', '-23', + '+09:00', '-05:30', '+00:01', '+23:59', '-23:59', '+14:00', '-12:00', + '+0900', '-0530', '+2359', '-2359', + ] + + # An offset that is not a valid time of day. Either it carries into the hours + # and silently means a different offset, or it leaves the range Time#localtime + # can apply and fails on every record. + INVALID = [ + '+09:60', '+0960', '+00:75', '-05:99', '+23:60', + '+24:00', '-24:00', '+99:99', '-99:99', '+2400', + ] + + def test_validate_accepts_offsets_within_a_day + VALID.each do |timezone| + assert_true Fluent::Timezone.validate(timezone), "#{timezone} should be valid" + end + end + + def test_validate_rejects_offsets_outside_a_day + INVALID.each do |timezone| + assert_false Fluent::Timezone.validate(timezone), "#{timezone} should be invalid" + assert_raise(Fluent::ConfigError.new("Unsupported timezone '#{timezone}'")) do + Fluent::Timezone.validate!(timezone) + end + end + end + + def test_utc_offset_resolves_the_offsets_time_can_apply + VALID.each do |timezone| + offset = Fluent::Timezone.utc_offset(timezone) + assert_true offset.is_a?(Integer), "#{timezone} does not resolve to an offset: #{offset.inspect}" + assert_true offset.abs < 86400, "#{timezone} resolves out of range: #{offset}" + end + + assert_equal 32400, Fluent::Timezone.utc_offset('+09:00') + assert_equal 32400, Fluent::Timezone.utc_offset('+0900') + assert_equal 32400, Fluent::Timezone.utc_offset('+09') + assert_equal(-19800, Fluent::Timezone.utc_offset('-0530')) + + INVALID.each do |timezone| + assert_nil Fluent::Timezone.utc_offset(timezone), "#{timezone} must not resolve to an offset" + end + end + + def test_formatter_builds_a_formatter_for_valid_offsets + formatter = Fluent::Timezone.formatter('+09:00', '%Y-%m-%dT%H:%M:%S%z') + assert_not_nil formatter + assert_equal '1970-01-01T09:00:00+0900', formatter.call(0) + + # '+09:60' means ten o'clock UTC only because the minutes are carried, so it + # must not be accepted as a stand-in for '+10:00'. + assert_nil Fluent::Timezone.formatter('+09:60', '%Y-%m-%dT%H:%M:%S%z') + end +end From 34c84a99a6b28ad143d735358a49c806354e2ef9 Mon Sep 17 00:00:00 2001 From: Qingyuan Wang <162230229@nuaa.edu.cn> Date: Tue, 27 Oct 2026 11:37:40 +0000 Subject: [PATCH 2/3] timezone: reject unsupported zones where they are configured The bounds make an out-of-range offset resolve to nil instead of to a shifted hour, and the three callers that build a formatter or a parser from a timezone read nil as "no timezone was given": TimeFormatter and TimeParser fall back to the host timezone, and Parser#build_type_converters re-reads the whole option as the time format. A typo then changes the records instead of stopping the config from loading. Both helpers copy TimeMixin::TIME_PARAMETERS but never called validate!, so do it there, as TimeParameters#configure does. In types, a value that starts with a sign followed by a digit can only be an attempted offset -- a strftime format never starts that way -- so raise instead of guessing. With TZ=America/New_York and event time 0, timezone +09:60 now raises where master formatted it as 1970-01-01T10:00:00+1000 and this PR previously emitted 1969-12-31T19:00:00-0500; +24:00 raises at configure instead of ArgumentError per record; timezone +0960 and types {"date":"time:+0960:%Y-%m-%d %H:%M:%S"} raise instead of yielding 104400 and nil. The tests go through the helpers and the parser, and keep the valid spellings (+10:00, +0100, Asia/Tokyo, and a bare time format) working so the rejection cannot over-reach. Signed-off-by: Qingyuan Wang <162230229@nuaa.edu.cn> --- lib/fluent/plugin/parser.rb | 6 +++++ lib/fluent/plugin_helper/extract.rb | 5 +++++ lib/fluent/plugin_helper/inject.rb | 5 +++++ lib/fluent/timezone.rb | 6 ++--- test/plugin/test_parser.rb | 34 +++++++++++++++++++++++++++++ test/plugin_helper/test_extract.rb | 20 +++++++++++++++++ test/plugin_helper/test_inject.rb | 25 +++++++++++++++++++++ test/test_timezone.rb | 2 -- 8 files changed, 98 insertions(+), 5 deletions(-) diff --git a/lib/fluent/plugin/parser.rb b/lib/fluent/plugin/parser.rb index d8fbde89a9..1400a20948 100644 --- a/lib/fluent/plugin/parser.rb +++ b/lib/fluent/plugin/parser.rb @@ -248,6 +248,12 @@ def build_type_converters(types) time_type = 'string' # estimate timezone, time_format = option.split(':', 2) unless Fluent::Timezone.validate(timezone) + # A signed value is spelled as a timezone, so re-reading the + # whole option as the time format would parse every record in + # the host timezone instead of failing here. + if timezone.match?(/\A[+-]\d/) + raise Fluent::ConfigError, "Unsupported timezone '#{timezone}' for key:'#{field_name}'" + end timezone, time_format = nil, option end if Fluent::TimeMixin::TIME_TYPES.include?(time_format) diff --git a/lib/fluent/plugin_helper/extract.rb b/lib/fluent/plugin_helper/extract.rb index 70b3dfccc9..b9228e5750 100644 --- a/lib/fluent/plugin_helper/extract.rb +++ b/lib/fluent/plugin_helper/extract.rb @@ -78,6 +78,11 @@ def configure(conf) super if @extract_config + # An unsupported timezone resolves to nil, and TimeParser reads that + # as "no timezone was given", so without this the event time of every + # record would silently move to the host timezone. + Fluent::Timezone.validate!(@extract_config.timezone) if @extract_config.timezone + @_extract_tag_key = @extract_config.tag_key @_extract_keep_tag_key = @extract_config.keep_tag_key @_extract_time_key = @extract_config.time_key diff --git a/lib/fluent/plugin_helper/inject.rb b/lib/fluent/plugin_helper/inject.rb index 6960ee45a6..a3ab897b50 100644 --- a/lib/fluent/plugin_helper/inject.rb +++ b/lib/fluent/plugin_helper/inject.rb @@ -104,6 +104,11 @@ def configure(conf) super if @inject_config + # An unsupported timezone resolves to nil, and TimeFormatter reads + # that as "no timezone was given", so this is the only place where + # such a typo in the configuration can be reported. + Fluent::Timezone.validate!(@inject_config.timezone) if @inject_config.timezone + @_inject_hostname_key = @inject_config.hostname_key if @_inject_hostname_key if self.respond_to?(:buffer_config) diff --git a/lib/fluent/timezone.rb b/lib/fluent/timezone.rb index 1f92aadcd6..0c8d2c6175 100644 --- a/lib/fluent/timezone.rb +++ b/lib/fluent/timezone.rb @@ -46,9 +46,9 @@ class Timezone # 4. Region/Zone (e.g. "Asia/Tokyo") # 5. Region/Zone/Zone (e.g. "America/Argentina/Buenos_Aires") # - # In the numeric forms, HH is 00..23 and MM is 00..59: that is exactly the - # range of offsets Time can apply, so a value outside it is either a - # different offset carried into the hours or one that fails when formatted. + # In the numeric forms, HH must be 00..23 and MM must be 00..59. + # Time.zone_offset does not check these ranges. It reads "+09:60" as + # "+10:00", and Time#localtime rejects offsets of 24 hours or more. # # In the 4th and 5th cases, it is checked whether the specified # timezone exists in the timezone database. diff --git a/test/plugin/test_parser.rb b/test/plugin/test_parser.rb index 551f89be29..b6aa82110c 100644 --- a/test/plugin/test_parser.rb +++ b/test/plugin/test_parser.rb @@ -219,6 +219,40 @@ def test_parse end end + sub_test_case '#build_type_converters time with an unsupported timezone' do + setup do + @i = ExampleParser.new + end + + def types_conf(field, definition) + { 'types' => JSON.dump(field => definition) } + end + + test 'raises ConfigError instead of reading the timezone as the time format' do + # '+0960' used to mean +10:00. Falling back to the whole option as the time + # format instead made every value convert to nil in the host timezone. + assert_raise(Fluent::ConfigError.new("Unsupported timezone '+0960' for key:'date'")) do + @i.configure(config_element('parse', '', types_conf('date', 'time:+0960:%Y-%m-%d %H:%M:%S'))) + end + end + + test 'still converts a supported numeric timezone' do + @i.configure(config_element('parse', '', types_conf('date', 'time:+0100:%Y-%m-%d %H:%M:%S'))) + c = @i.type_converters['date'] + with_timezone("UTC+02") do # -0200 + assert_equal_event_time event_time("2026-10-26 11:00:00 +0000"), c.call("2026-10-26 12:00:00") + end + end + + test 'still converts a time format that is not a timezone' do + @i.configure(config_element('parse', '', types_conf('date', 'time:%Y-%m-%d %H:%M:%S'))) + c = @i.type_converters['date'] + with_timezone("UTC+02") do # -0200 + assert_equal_event_time event_time("2026-10-26 12:00:00 -0200"), c.call("2026-10-26 12:00:00") + end + end + end + sub_test_case 'example parser without any configurations' do setup do @current_time = Time.parse("2016-10-21 14:22:01.0 +1000") diff --git a/test/plugin_helper/test_extract.rb b/test/plugin_helper/test_extract.rb index 88542cd31c..2b611763a9 100644 --- a/test/plugin_helper/test_extract.rb +++ b/test/plugin_helper/test_extract.rb @@ -190,5 +190,25 @@ def config_extract_section(hash = {}) end assert_equal_event_time(event_time("2016-09-06 13:27:01 +0900"), t) end + + # An offset that does not resolve is read by TimeParser as "no timezone", + # which used to move the event time to the host timezone silently. + test 'raises ConfigError for a timezone out of the numeric offset range' do + c = config_extract_section("time_key" => "t", "time_type" => "string", "time_format" => "%H:%M:%S, %m/%d/%Y", "timezone" => "+0960") + assert_raise Fluent::ConfigError.new("Unsupported timezone '+0960'") do + @d.configure(c) + end + end + + test 'returns EventTime object, parsed as configured time_format in a named timezone' do + t = with_timezone("UTC-02") do + c = config_extract_section("time_key" => "t", "time_type" => "string", "time_format" => "%H:%M:%S, %m/%d/%Y", "timezone" => "Asia/Tokyo") + @d.configure(c) + @d.start + @d.after_start + @d.extract_time_from_record({"t" => "13:27:01, 09/06/2016", "message" => "yay!"}) + end + assert_equal_event_time(event_time("2016-09-06 13:27:01 +0900"), t) + end end end diff --git a/test/plugin_helper/test_inject.rb b/test/plugin_helper/test_inject.rb index b17f63f5eb..adad04d4d6 100644 --- a/test/plugin_helper/test_inject.rb +++ b/test/plugin_helper/test_inject.rb @@ -557,5 +557,30 @@ def test_utc_timezone assert_equal("2014-09-27T09:00:00+09:00", record['t']) end end + + # An offset outside HH 00..23 / MM 00..59 resolves to nil, which TimeFormatter + # reads as "no timezone", so these used to format every record in the host + # timezone with nothing in the log to point at the typo. + def test_timezone_out_of_range + assert_raise Fluent::ConfigError.new("Unsupported timezone '+09:60'") do + @d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "timezone" => "+09:60")) + end + end + + def test_timezone_of_24_hours + # On master this configured and then raised ArgumentError per record. + assert_raise Fluent::ConfigError.new("Unsupported timezone '+24:00'") do + @d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "timezone" => "+24:00")) + end + end + + def test_timezone_within_the_range_is_still_accepted + with_timezone("UTC-01") do + @d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "time_format" => "%Y-%m-%dT%H:%M:%S%z", "timezone" => "+10:00")) + @d.start + record = @d.inject_values_to_record('tag', @time, {}) + assert_equal("2014-09-27T10:00:00+1000", record['t']) + end + end end end diff --git a/test/test_timezone.rb b/test/test_timezone.rb index ee492e1769..23e648a320 100644 --- a/test/test_timezone.rb +++ b/test/test_timezone.rb @@ -19,8 +19,6 @@ class TimezoneTest < ::Test::Unit::TestCase # [+-]HH, [+-]HHMM and [+-]HH:MM with HH in 00..23 and MM in 00..59. - # These are exactly the offsets that Time#localtime accepts: the largest of - # them, +/-23:59, is 86340, one second inside the +-86400 boundary. VALID = [ '+00', '-00', '+09', '-09', '+23', '-23', '+09:00', '-05:30', '+00:01', '+23:59', '-23:59', '+14:00', '-12:00', From f45d7c2ff172c2fc1be1a5473f0e00ea0d499246 Mon Sep 17 00:00:00 2001 From: Qingyuan Wang <162230229@nuaa.edu.cn> Date: Sat, 31 Oct 2026 10:30:12 +0000 Subject: [PATCH 3/3] timezone: match the whole value when detecting numeric timezones in parsers The guard in build_type_converters raised for any option whose timezone part starts with a sign and a digit, but a time format may legitimately start with a signed numeric token, e.g. `types {"date":"time:+0000 %Y-%m-%d %H:%M:%S"}` parses on master. It also raised NoMethodError for `types {"date":"time:"}`, where the timezone part is nil. Match the whole value instead so only a lone numeric token is rejected, and add regression tests for both cases. Signed-off-by: Qingyuan Wang <162230229@nuaa.edu.cn> --- lib/fluent/plugin/parser.rb | 8 ++++---- test/plugin/test_parser.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/fluent/plugin/parser.rb b/lib/fluent/plugin/parser.rb index 1400a20948..cb7e234224 100644 --- a/lib/fluent/plugin/parser.rb +++ b/lib/fluent/plugin/parser.rb @@ -248,10 +248,10 @@ def build_type_converters(types) time_type = 'string' # estimate timezone, time_format = option.split(':', 2) unless Fluent::Timezone.validate(timezone) - # A signed value is spelled as a timezone, so re-reading the - # whole option as the time format would parse every record in - # the host timezone instead of failing here. - if timezone.match?(/\A[+-]\d/) + # Treat a signed numeric token as a timezone. Re-reading the + # whole option as the time format would silently produce nil + # values instead of failing here. + if /\A[+-]\d+\z/.match?(timezone) raise Fluent::ConfigError, "Unsupported timezone '#{timezone}' for key:'#{field_name}'" end timezone, time_format = nil, option diff --git a/test/plugin/test_parser.rb b/test/plugin/test_parser.rb index b6aa82110c..26b8cabe9f 100644 --- a/test/plugin/test_parser.rb +++ b/test/plugin/test_parser.rb @@ -251,6 +251,24 @@ def types_conf(field, definition) assert_equal_event_time event_time("2026-10-26 12:00:00 -0200"), c.call("2026-10-26 12:00:00") end end + + test 'parses a time format that starts with a signed numeric token' do + # A time format may start with a sign and digits, e.g. a literal "+0000". + # Such a format is not a timezone and must not be rejected. + @i.configure(config_element('parse', '', types_conf('date', 'time:+0000 %Y-%m-%d %H:%M:%S'))) + c = @i.type_converters['date'] + with_timezone("UTC+02") do # -0200 + assert_equal_event_time event_time("2026-10-26 12:00:00 -0200"), c.call("+0000 2026-10-26 12:00:00") + end + end + + test 'does not raise for an empty time definition' do + # `time:` has neither a timezone nor a format. The numeric check used to + # raise NoMethodError on nil during configure. + assert_nothing_raised do + @i.configure(config_element('parse', '', types_conf('date', 'time:'))) + end + end end sub_test_case 'example parser without any configurations' do