From 0480f51ab3d4283e64c645bd15bea1348f1ffc76 Mon Sep 17 00:00:00 2001 From: Dean Hamstead Date: Wed, 26 Aug 2026 21:55:57 +1000 Subject: [PATCH] Bound Repeatable counter_name to prevent CPU/memory DoS (CVE-2026-19873) Add max_counter attribute to Repeatable elements that caps the client-supplied repeat count from the query string. Default is 100, inherited from a new form-level repeatable_max_counter attribute. Additional hardening beyond the base patch: - counter_clamped read-only flag signals when clamping occurred - max_counter=0 means unlimited (safer than undef escape hatch) - Form-level repeatable_max_counter sets the default for all Repeatable elements in the form --- lib/HTML/FormFu.pm | 20 +++++ lib/HTML/FormFu/Element/Repeatable.pm | 54 ++++++++++- t/repeatable/max_counter.t | 123 ++++++++++++++++++++++++++ t/repeatable/max_counter.yml | 11 +++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 t/repeatable/max_counter.t create mode 100644 t/repeatable/max_counter.yml diff --git a/lib/HTML/FormFu.pm b/lib/HTML/FormFu.pm index b94d5047..733c0069 100644 --- a/lib/HTML/FormFu.pm +++ b/lib/HTML/FormFu.pm @@ -111,6 +111,13 @@ has form_error_message_class => ( lazy => 1, ); +has repeatable_max_counter => ( + is => 'rw', + default => 100, + lazy => 1, + traits => ['Chained'], +); + our @MULTIFORM_SHARED = ( qw( javascript javascript_src @@ -1485,6 +1492,19 @@ used as the return value for L. If L is not set, L will return true if a value for any known fieldname was submitted. +=head2 repeatable_max_counter + +Arguments: $number + +Default Value: C<100> + +The default L +for all L elements in this +form. Individual Repeatable elements can override this by setting their own +L. + +Set to C<0> to disable clamping form-wide. + =head2 auto_fieldset Arguments: 1 diff --git a/lib/HTML/FormFu/Element/Repeatable.pm b/lib/HTML/FormFu/Element/Repeatable.pm index 1769e547..cb60b23a 100644 --- a/lib/HTML/FormFu/Element/Repeatable.pm +++ b/lib/HTML/FormFu/Element/Repeatable.pm @@ -14,6 +14,23 @@ use Carp qw( croak ); has counter_name => ( is => 'rw', traits => ['Chained'] ); +has max_counter => ( + is => 'rw', + default => sub { + my $form = $_[0]->form; + return $form->repeatable_max_counter if $form; + return 100; + }, + lazy => 1, + traits => ['Chained'], +); + +has counter_clamped => ( + is => 'ro', + default => 0, + writer => '_set_counter_clamped', +); + has _original_elements => ( is => 'rw' ); has increment_field_names => ( @@ -261,6 +278,8 @@ sub process { my $form = $self->form; my $count = 1; + $self->_set_counter_clamped(0); + if ( defined $counter_name && defined $form->query ) { # are we in a nested-repeatable? @@ -279,7 +298,15 @@ sub process { my $input = $form->query->param($counter_name); if ( defined $input && $input =~ /^[1-9][0-9]*\z/ ) { - $count = $input; + my $max = $self->max_counter; + + if ( defined $max && $max > 0 && $input > $max ) { + $count = $max; + $self->_set_counter_clamped(1); + } + else { + $count = $input; + } } } @@ -426,6 +453,31 @@ present on the form during L, no Processors (Constraints, etc.) will be run on the fields, and their values will not be returned by L or L. +=head2 max_counter + +Arguments: $number + +Default Value: C<100> + +The largest repeat count that will be accepted from the L +query parameter. A larger client-supplied value is clamped to this number. + +The default is inherited from L, so +setting that on the form sets the default for all its Repeatable elements. + +Set to C<0> to accept any client-supplied count without clamping. Doing so +lets a single request drive an unbounded number of element clones. + +Calling L from application code is not affected by this setting, +so a form repeated once per database row is unaffected however many rows +there are. + +=head2 counter_clamped + +Read-only boolean. True if the most recent L call +clamped the client-supplied count to L. Reset to false at the +start of each L call. + =head2 increment_field_names Arguments: $bool diff --git a/t/repeatable/max_counter.t b/t/repeatable/max_counter.t new file mode 100644 index 00000000..ba51f325 --- /dev/null +++ b/t/repeatable/max_counter.t @@ -0,0 +1,123 @@ +use strict; +use warnings; + +use Test::More tests => 13; + +use HTML::FormFu; +use lib 't/lib'; +use HTMLFormFu::TestLib; + +# Test that max_counter defaults to 100 and clamps large values +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + + is( $repeatable->max_counter, 100, 'default max_counter is 100' ); + + $form->process( { count => 200 } ); + + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 100, '200 repeats clamped to 100' ); + ok( $repeatable->counter_clamped, 'counter_clamped is true after clamping' ); +} + +# Test that max_counter can be set lower +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + $repeatable->max_counter(5); + + $form->process( { count => 10 } ); + + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 5, '10 repeats clamped to 5' ); + ok( $repeatable->counter_clamped, 'counter_clamped is true' ); +} + +# Test that max_counter=0 means unlimited (not clamped) +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + $repeatable->max_counter(0); + + $form->process( { count => 3 } ); + + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 3, 'max_counter=0 allows 3 repeats unclamped' ); + ok( !$repeatable->counter_clamped, 'counter_clamped is false' ); +} + +# Test that normal small values are unaffected +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + $form->process( { count => 3 } ); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 3, '3 repeats below default max_counter' ); + ok( !$repeatable->counter_clamped, 'counter_clamped is false' ); +} + +# Test that calling repeat() directly is unaffected by max_counter +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + $repeatable->repeat(200); + + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 200, 'direct repeat(200) unaffected by max_counter' ); +} + +# Test form-level repeatable_max_counter +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' }, + repeatable_max_counter => 50, + } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + + is( $repeatable->max_counter, 50, + 'max_counter inherits form repeatable_max_counter' ); + + $form->process( { count => 200 } ); + + my @blocks = @{ $repeatable->get_elements }; + is( scalar @blocks, 50, '200 clamped to form-level 50' ); +} + +# Test chained method works +{ + my $form = HTML::FormFu->new( + { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } ); + + $form->load_config_file('t/repeatable/max_counter.yml'); + + my $repeatable = $form->get_element( { type => 'Repeatable' } ); + + $repeatable->max_counter(10)->max_counter(20); + + is( $repeatable->max_counter, 20, 'chained max_counter setter works' ); +} diff --git a/t/repeatable/max_counter.yml b/t/repeatable/max_counter.yml new file mode 100644 index 00000000..2166822b --- /dev/null +++ b/t/repeatable/max_counter.yml @@ -0,0 +1,11 @@ +--- +elements: + - type: Hidden + name: count + + - type: Repeatable + nested_name: rep + increment_field_names: 1 + counter_name: count + elements: + - name: foo