diff --git a/cron/cron-lib.pl b/cron/cron-lib.pl index 38b91fdce..1587b698f 100755 --- a/cron/cron-lib.pl +++ b/cron/cron-lib.pl @@ -1079,6 +1079,8 @@ if ($in{'special_def'}) { foreach $arr (@pers) { delete($job->{$arr}); } + # Special times are single words like daily or reboot + $in{'special'} =~ /^[a-z]+$/i || &error($text{'save_especial'}); $job->{'special'} = $in{'special'}; } else { @@ -1092,6 +1094,11 @@ else { # Need to work out and simplify ranges selected local (@range, @newrange, $i); @range = split(/\0/, $in{$arr}); + # Selected times are plain numbers + foreach my $v (@range) { + $v =~ /^\d+$/ || &error(&text('save_etime', + $text{"edit_$arr"})); + } @range = sort { $a <=> $b } @range; local $start = -1; for($i=0; $i<@range; $i++) { @@ -1168,6 +1175,11 @@ if ($in->{'range_def'}) { else { # Validate and store range foreach my $r ("start", "end") { + # Dates go into the job's command, so only allow numbers + foreach my $f ("day", "month", "year") { + $in->{'range_'.$r.'_'.$f} =~ /^\d+$/ || + &error($text{'range_e'.$r}); + } eval { timelocal(0, 0, 0, $in->{'range_'.$r.'_day'}, $in->{'range_'.$r.'_month'}-1, $in->{'range_'.$r.'_year'}-1900) }; diff --git a/cron/lang/en b/cron/lang/en index 0141128a1..a51b1cc49 100644 --- a/cron/lang/en +++ b/cron/lang/en @@ -81,6 +81,9 @@ lcedit_special_reboot=when system boots save_err=Failed to save cron job save_ecmd=You did not enter a command to execute +save_enewline=The command and comment cannot contain line breaks +save_especial=Invalid special time selected +save_etime=Invalid $1 selected save_ename=Missing or invalid environment variable name save_evalue=Invalid environment variable value save_euser=You must select a user diff --git a/cron/save_cron.cgi b/cron/save_cron.cgi index 821156871..6a8edb651 100755 --- a/cron/save_cron.cgi +++ b/cron/save_cron.cgi @@ -38,6 +38,10 @@ foreach $f (@files) { &lock_file($f); } if ($in{"cmd"} !~ /\S/ && $access{'command'}) { &error($text{'save_ecmd'}); } +# A line break would add extra lines to the crontab +if ($in{'cmd'} =~ /[\r\n]/ || $in{'comment'} =~ /[\r\n]/) { + &error($text{'save_enewline'}); + } if (&supports_users()) { if (!$in{'user'}) { &error($text{'save_euser'}); diff --git a/cron/t/run-tests.t b/cron/t/run-tests.t index 3a2e8525b..0c3a68ab1 100644 --- a/cron/t/run-tests.t +++ b/cron/t/run-tests.t @@ -81,15 +81,23 @@ ok(!cron_move_affects_env(\@jobs, $jobs[0], $jobs[6]), my $cronfile = "$vardir/crontab"; my $crondir = "$vardir/crontabs"; make_path($crondir); -open(my $modfh, ">", "$confdir/cron/config") or die "cron config: $!"; -print $modfh "single_file=$cronfile\n"; -print $modfh "cron_dir=$crondir\n"; -print $modfh "system_crontab=$vardir/system-crontab\n"; -print $modfh "cronfiles_dir=$vardir/cron.d\n"; -print $modfh "vixie_cron=1\n"; -close($modfh); make_path("$vardir/cron.d"); +# Write the Cron module config, with the given lines added. +sub write_cron_config +{ +my (@lines) = @_; +open(my $fh, ">", "$confdir/cron/config") or die "cron config: $!"; +print $fh map { "$_\n" } (@lines, + "cron_dir=$crondir", + "system_crontab=$vardir/system-crontab", + "cronfiles_dir=$vardir/cron.d", + "vixie_cron=1", + "cron_deny_all=2"); +close($fh); +} +write_cron_config("single_file=$cronfile"); + # Reset the crontab fixture to its original contents. sub write_crontab { @@ -246,6 +254,39 @@ like($result->{'out'}, qr/^Location:/m, is(read_crontab_fixture(), $original, "re-enabling the normal job restores the fixture"); +# Job fields must not add lines or fields to the crontab +seed_acl(%full_acl); +my %edit = ( 'idx' => 0, 'user' => 'root', 'active' => 1, + 'cmd' => 'echo first', 'range_def' => 1, + 'special_def' => 0, 'all_mins' => 1, 'all_hours' => 1, + 'all_days' => 1, 'all_months' => 1, 'all_weekdays' => 1 ); +foreach my $case ( + [ "a line break in the command", + { 'cmd' => "echo first\nPATH=/tmp" }, qr/cannot contain line breaks/ ], + [ "a line break in the comment", + { 'comment' => "x\nPATH=/tmp" }, qr/cannot contain line breaks/ ], + [ "a special time with spaces", + { 'special_def' => 1, 'special' => "reboot echo x #" }, + qr/Invalid special time/ ], + [ "a minute with spaces", + { 'all_mins' => 0, 'mins' => "* * * * * echo x #" }, + qr/Invalid Minutes selected/ ], + [ "a minute with a line break", + { 'all_mins' => 0, 'mins' => "5\nPATH=/tmp" }, + qr/Invalid Minutes selected/ ], + [ "a range date with shell text", + { 'range_def' => 0, 'range_start_day' => '1;echo x;', + 'range_start_month' => 1, 'range_start_year' => 2026, + 'range_end_day' => 31, 'range_end_month' => 12, + 'range_end_year' => 2026 }, qr/invalid start date/i ], + ) { + my ($desc, $form, $re) = @$case; + $result = run_cgi("save_cron.cgi", { %edit, %$form }); + like($result->{'out'}, $re, "save_cron.cgi rejects $desc"); + is(read_crontab_fixture(), $original, + "rejected $desc leaves the crontab unchanged"); + } + seed_acl(%full_acl); $result = run_cgi("move.cgi", { 'idx' => 4, 'top' => 1 }); like($result->{'out'}, qr/^Location:/m, @@ -253,4 +294,23 @@ like($result->{'out'}, qr/^Location:/m, isnt(read_crontab_fixture(), $original, "the permitted top move changes the crontab"); +# Saving a job needs a real Unix user, so check a valid save in a per-user +# crontab, as on Linux. Plain shell commands stand in for the crontab +# command, so the real one never runs. +write_cron_config("cron_copy_command=cat >$crondir/USER", + "cron_delete_command=rm -f $crondir/USER"); +$cronfile = "$crondir/root"; +write_crontab(); +$result = run_cgi("save_cron.cgi", + { %edit, 'all_mins' => 0, 'mins' => [ 5, 6, 30 ], + 'range_def' => 0, 'range_start_day' => 1, + 'range_start_month' => 1, 'range_start_year' => 2026, + 'range_end_day' => 31, 'range_end_month' => 12, + 'range_end_year' => 2026 }); +like($result->{'out'}, qr/^Location:/m, + "save_cron.cgi accepts valid times and dates"); +like(read_crontab_fixture(), + qr/^5-6,30 \* \* \* \* \S+range\.pl 1-1-2026 31-12-2026 echo\\ first$/m, + "the saved job has the expected times and date range"); + done_testing(); diff --git a/t/web-lib-funcs-encoding.t b/t/web-lib-funcs-encoding.t index b6649cb34..c7d83cdd5 100644 --- a/t/web-lib-funcs-encoding.t +++ b/t/web-lib-funcs-encoding.t @@ -173,6 +173,40 @@ subtest 'convert_to_json / convert_from_json' => sub { is_deeply(main::convert_from_json($pretty), {a=>1,b=>2}, 'pretty output still parses'); + # Sorting is independent of whitespace, with the same contract in both engines. + for my $engine ('JSON::PP', 'JSON::XS') { + subtest "$engine canonical output" => sub { + eval "require $engine; 1" or plan skip_all => "$engine is not installed"; + # Force the supported PP fallback even when XS is installed. + local $INC{'JSON/XS.pm'} = $engine eq 'JSON::PP' ? undef : $INC{'JSON/XS.pm'}; + my $data = {z => 2, a => {z => 1, a => 0}}; + is(main::convert_to_json($data, 0, 1, 1), + '{"a":{"a":0,"z":1},"z":2}', 'compact output sorts nested keys'); + is(main::convert_to_json($data, 1), + $engine->new->pretty->canonical->latin1->encode($data), + 'existing pretty calls retain sorted output'); + is(main::convert_to_json($data, 1, 0, 0), + $engine->new->pretty->latin1->encode($data), + 'explicit false disables sorting independently of pretty printing'); + is(main::convert_to_json($data, 0, 0, undef), + $engine->new->latin1->encode($data), + 'undefined canonical flag retains the default'); + # JSON syntax and byte encoding must remain independent of sorting. + my $unicode = {label => "Caf\x{e9} \x{65e5}\x{672c} \x{1f642}"}; + my $bytes = main::convert_to_json($unicode, 0, 1, 1); + ok(!utf8::is_utf8($bytes), 'UTF-8 mode returns bytes'); + is_deeply(main::convert_from_json($bytes), $unicode, 'Unicode round-trips'); + is($bytes, JSON::PP->new->canonical->utf8->encode($unicode), + 'compact UTF-8 matches the existing canonical encoding'); + my $backup = {content => join('', map { chr } 0..255)}; + my $raw = main::convert_to_json($backup, 0, 0, 1); + is($raw, JSON::PP->new->canonical->encode($backup), + 'raw configuration bytes retain their existing serialized form'); + is_deeply(main::convert_from_json($raw, 1), $backup, + 'raw configuration bytes round-trip'); + }; + } + # Current contract: undef input becomes {} (the `||= {}` default). is(main::convert_to_json(undef), '{}', 'undef input → "{}"'); diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index f32ce3983..b8551cddd 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -13849,18 +13849,20 @@ for(my $i=0; $i<@sp1 || $i<@sp2; $i++) { return 0; } -=head2 convert_to_json(data, [pretty], [raw-utf8]) +=head2 convert_to_json(data, [pretty], [raw-utf8], [canonical]) Converts the given Perl data structure to encoded binary string =item data parameter is a hash/array reference -=item if the output should be prettified and hash keys sorted +=item pretty parameter enables indentation and newlines; also sorts hash keys unless canonical is supplied =item raw-utf8 parameter, if set to 1, encodes data using UTF-8 +=item canonical parameter controls hash key sorting independently of pretty printing; defaults to pretty + =cut sub convert_to_json { -my ($data, $pretty, $raw_utf8) = @_; +my ($data, $pretty, $raw_utf8, $canonical) = @_; my $json; if (eval { require JSON::XS }) { @@ -13873,7 +13875,8 @@ else { error("Neither JSON::XS nor JSON::PP Perl module is available on your system"); } $json->pretty(!!$pretty); -$json->canonical(1) if ($pretty); +# Preserve sorting for existing pretty calls unless explicitly overridden. +$json->canonical(defined($canonical) ? !!$canonical : !!$pretty); $data ||= {}; return $raw_utf8 ? $json->utf8->encode($data) : $json->latin1->encode($data); }