diff --git a/lpadmin/list_jobs.cgi b/lpadmin/list_jobs.cgi index 456aaf264..fef5ed9e5 100755 --- a/lpadmin/list_jobs.cgi +++ b/lpadmin/list_jobs.cgi @@ -34,8 +34,9 @@ if (@jobs) { push(@cols, &html_escape($j->{'id'})); if ($j->{'printfile'} && &can_edit_jobs($in{'name'}, $ju)) { push(@cols, - "{'id'}'>".&nice_size($j->{'size'}).""); + "". + &nice_size($j->{'size'}).""); $printfile++; } else { @@ -51,7 +52,8 @@ if (@jobs) { push(@cols, $j->{'when'}); } if ($j->{'file'}) { - push(@cols, $j->{'file'}); + # Job titles and filenames can be supplied by print clients. + push(@cols, &html_escape($j->{'file'})); } if (&can_edit_jobs($in{'name'}, $ju) && $access{'cancel'}) { diff --git a/lpadmin/test_form.cgi b/lpadmin/test_form.cgi index 117d5c100..9079025df 100755 --- a/lpadmin/test_form.cgi +++ b/lpadmin/test_form.cgi @@ -9,8 +9,8 @@ $access{'test'} || &error($text{'test_ecannot'}); $text{'test_title'}, ""); print "
\n"; -print "\n"; -print &text('test_desc', "$in{'name'}"),"

\n"; +print &ui_hidden("name", $in{'name'}); +print &text('test_desc', "".&html_escape($in{'name'}).""),"

\n"; print " $text{'test_0'}
\n"; print " $text{'test_1'}
\n"; diff --git a/t/fixtures/lpadmin-lpq.pl b/t/fixtures/lpadmin-lpq.pl new file mode 100644 index 000000000..32ffd230a --- /dev/null +++ b/t/fixtures/lpadmin-lpq.pl @@ -0,0 +1,9 @@ +#!/usr/bin/perl +# Emit a queue fixture instead of querying the workstation's print service. +use strict; +use warnings; + +print "Rank Owner Job Files Total Size\n"; +if (defined($ENV{'WEBMIN_TEST_PRINT_TITLE'})) { + print "1st fixture 12 $ENV{'WEBMIN_TEST_PRINT_TITLE'} 123 bytes\n"; + } diff --git a/t/lpadmin-escaping.t b/t/lpadmin-escaping.t new file mode 100644 index 000000000..e02d8fccc --- /dev/null +++ b/t/lpadmin-escaping.t @@ -0,0 +1,130 @@ +#!/usr/bin/perl +# Render printer CGIs with real UI helpers and isolated CUPS queue output. +use strict; +use warnings; +no warnings qw(once redefine uninitialized); +use Test::More; +use FindBin; +use File::Temp qw(tempdir); + +my $root = "$FindBin::Bin/.."; +require "$root/web-lib-funcs.pl"; +require "$root/ui-lib.pl"; +$INC{'./lpadmin-lib.pl'} = 1; +*main::ReadParse = sub { }; +*main::error = sub { die join(' ', @_)."\n" }; +*main::ui_print_header = sub { print main::ui_post_header($_[0]) }; +*main::ui_print_footer = sub { }; +*main::get_module_variable = sub { return \%main::text }; +*main::make_date = sub { 'Fixture date' }; + +open(my $lang, '<', "$root/lpadmin/lang/en") or die $!; +while (<$lang>) { + $main::text{$1} = $2 if /^(\S+)=(.*)$/; + } +close($lang); + +# Load the real parser and access check without module initialization. +for my $source ([ 'cups-lib.pl', 'get_jobs' ], + [ 'lpadmin-lib.pl', 'can_edit_jobs' ]) { + open(my $fh, '<', "$root/lpadmin/$source->[0]") or die $!; + my $code = do { local $/; <$fh> }; + $code =~ /(^sub \Q$source->[1]\E\n\{.*?^\})/ms or die "Missing $source->[1]"; + eval 'no strict; no warnings; '.$1; + die $@ if $@; + } + +# Keep spool reads and the lpq subprocess inside temporary test fixtures. +my $spool = tempdir('webmin-printer-tests.XXXXXX', DIR => '/tmp', CLEANUP => 1); +open(my $job, '>', "$spool/d00012-001") or die $!; +print $job "Fixture print data\n"; +close($job); +$main::lpq = main::quote_path($^X).' '.main::quote_path("$root/t/fixtures/lpadmin-lpq.pl"); +$main::config{'spool_dir'} = $spool; + +# render_cgi(file, name, access) captures a real CGI without submitting jobs. +sub render_cgi +{ +my ($file, $name, $access) = @_; +local %main::in = ( name => $name ); +local %main::access = %$access; +local @main::tds; +local $main::printfile; +my $html = ''; +open(my $out, '>', \$html) or die $!; +{ + local *STDOUT = $out; + do "$root/lpadmin/$file.cgi"; + die $@ if $@; + } +close($out); +return $html; +} + +# check_inert(html, label) checks tags and attributes outside quoted values. +sub check_inert +{ +my ($html, $label) = @_; +$html =~ s/"[^"]*"|'[^']*'//g; +unlike($html, qr/<(?:img|svg)\b/i, "$label cannot add active elements"); +unlike($html, qr/\bon(?:error|load|focus|mouseover)\s*=/i, + "$label cannot add event attributes"); +} + +subtest 'test form printer names' => sub { + for my $name ('Office_Main', q{O'Reilly & Sons}, + q{x'>}, + q{x" autofocus onfocus="alert(1)}) { + my $html = render_cgi('test_form', $name, { test => 1 }); + check_inert($html, 'Printer name'); + like($html, qr/\Q@{[main::ui_hidden('name', $name)]}\E/, + 'Hidden field uses the attribute-escaping helper'); + my $desc = main::text('test_desc', ''.main::html_escape($name).''); + like($html, qr/\Q$desc\E/, + 'Description preserves trusted formatting and escapes the name'); + like($html, qr/action=test_print\.cgi method=post/, + 'Test-page submission target is unchanged'); + } + my $ok = eval { render_cgi('test_form', 'Office_Main', { test => 0 }); 1 }; + ok(!$ok, 'Test-print permission remains required'); + like($@, qr/\Q$main::text{'test_ecannot'}\E/, 'Permission error is unchanged'); + }; + +subtest 'CUPS job titles and queue controls' => sub { + for my $title ('Quarterly report.pdf', q{Report's "draft" & notes.txt}, + q{}) { + local $ENV{'WEBMIN_TEST_PRINT_TITLE'} = $title; + for my $access ({ cancel => 0 }, { cancel => 1, user => '*' }, + { cancel => 1, user => 'someone-else' }) { + my $html = render_cgi('list_jobs', 'Office_Main', $access); + check_inert($html, 'Job title'); + like($html, qr/\Q@{[main::html_escape($title)]}\E/, + 'Parsed title is displayed as text'); + like($html, qr/Fixture date<\/span>/, + 'Trusted date markup still renders normally'); + my $editable = $access->{'cancel'} && $access->{'user'} eq '*'; + is($html =~ /href='view_job\.cgi\?/ ? 1 : 0, $editable ? 1 : 0, + 'Job viewing still follows access permissions'); + is($html =~ /type=['"]?checkbox\b/ ? 1 : 0, $editable ? 1 : 0, + 'Job cancellation still follows access permissions'); + } + } + }; + +subtest 'job-view URLs and empty queues' => sub { + my $name = q{x'>}; + local $ENV{'WEBMIN_TEST_PRINT_TITLE'} = 'Report.pdf'; + my $html = render_cgi('list_jobs', $name, { cancel => 1, user => '*' }); + check_inert($html, 'Job-view printer name'); + like($html, qr/\Qhref='view_job.cgi?name=@{[main::urlize($name)]}&id=12'\E/, + 'Job-view URL encodes the printer name'); + delete($ENV{'WEBMIN_TEST_PRINT_TITLE'}); + $html = render_cgi('list_jobs', $name, { cancel => 1, user => '*' }); + check_inert($html, 'Empty queue printer name'); + my $desc = main::text('jobs_none', ''.main::html_escape($name).''); + like($html, qr/\Q$desc\E/, + 'Empty queue message remains escaped'); + unlike($html, qr/action="cancel_all\.cgi"/, 'Empty queue has no cancel form'); + }; + +done_testing();