From ad04dac19b1fbba33cbb7dadb17ae0ef0423b2e1 Mon Sep 17 00:00:00 2001 From: Simon McVittie Date: Sat, 24 Dec 2016 15:58:10 +0000 Subject: [PATCH] Add automated test for using the CGI with git, including CVE-2016-10026 --- debian/changelog | 3 + debian/control | 1 + t/git-cgi.t | 300 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100755 t/git-cgi.t diff --git a/debian/changelog b/debian/changelog index b057ec7f2..c7d193825 100644 --- a/debian/changelog +++ b/debian/changelog @@ -13,6 +13,9 @@ ikiwiki (3.20161220) UNRELEASED; urgency=medium (CVE-2016-10026 represents the original vulnerability) (OVE-20161226-0002 represents the incomplete fix released in 3.20161219) * Add CVE references for CVE-2016-10026 + * Add automated test for using the CGI with git, including + CVE-2016-10026 + - Build-depend on libipc-run-perl for better build-time test coverage * Add missing ikiwiki.setup for the manual test for CVE-2016-10026 * git: don't issue a warning if the rcsinfo CGI parameter is undefined * git: do not fail to commit changes with a recent git version diff --git a/debian/control b/debian/control index 9f228c75b..47abb9a29 100644 --- a/debian/control +++ b/debian/control @@ -16,6 +16,7 @@ Build-Depends-Indep: libhtml-parser-perl, libhtml-scrubber-perl, libhtml-template-perl, + libipc-run-perl, libimage-magick-perl | perlmagick, libmagickcore-extra, libnet-openid-consumer-perl, diff --git a/t/git-cgi.t b/t/git-cgi.t new file mode 100755 index 000000000..7058c33e3 --- /dev/null +++ b/t/git-cgi.t @@ -0,0 +1,300 @@ +#!/usr/bin/perl +use warnings; +use strict; + +use Test::More; + +BEGIN { + my $git = `which git`; + chomp $git; + plan(skip_all => 'git not available') unless -x $git; + + plan(skip_all => "CGI not available") + unless eval q{ + use CGI qw(); + 1; + }; + + plan(skip_all => "IPC::Run not available") + unless eval q{ + use IPC::Run qw(run); + 1; + }; + + use_ok('IkiWiki'); + use_ok('YAML::XS'); +} + +# We check for English error messages +$ENV{LC_ALL} = 'C'; + +use Cwd qw(getcwd); +use Errno qw(ENOENT); + +my $installed = $ENV{INSTALLED_TESTS}; + +my @command; +if ($installed) { + @command = qw(ikiwiki); +} +else { + ok(! system("make -s ikiwiki.out")); + @command = ("perl", "-I".getcwd."/blib/lib", './ikiwiki.out', + '--underlaydir='.getcwd.'/underlays/basewiki', + '--set', 'underlaydirbase='.getcwd.'/underlays', + '--templatedir='.getcwd.'/templates'); +} + +sub write_old_file { + my $name = shift; + my $dir = shift; + my $content = shift; + writefile($name, $dir, $content); + ok(utime(333333333, 333333333, "$dir/$name")); +} + +sub write_setup_file { + my %setup = ( + wikiname => 'this is the name of my wiki', + srcdir => getcwd.'/t/tmp/in/doc', + destdir => getcwd.'/t/tmp/out', + url => 'http://example.com', + cgiurl => 'http://example.com/cgi-bin/ikiwiki.cgi', + cgi_wrapper => getcwd.'/t/tmp/ikiwiki.cgi', + cgi_wrappermode => '0751', + add_plugins => [qw(anonok lockedit recentchanges)], + disable_plugins => [qw(emailauth openid passwordauth)], + anonok_pagespec => 'writable/*', + locked_pages => '!writable/*', + rcs => 'git', + git_wrapper => getcwd.'/t/tmp/in/.git/hooks/post-commit', + git_wrappermode => '0754', + gitorigin_branch => '', + ); + unless ($installed) { + $setup{ENV} = { 'PERL5LIB' => getcwd.'/blib/lib' }; + } + writefile("test.setup", "t/tmp", + "# IkiWiki::Setup::Yaml - YAML formatted setup file\n" . + Dump(\%setup)); +} + +sub thoroughly_rebuild { + ok(unlink("t/tmp/ikiwiki.cgi") || $!{ENOENT}); + ok(unlink("t/tmp/in/.git/hooks/post-commit") || $!{ENOENT}); + ok(! system(@command, qw(--setup t/tmp/test.setup --rebuild --wrappers))); +} + +sub check_cgi_mode_bits { + my $mode; + + (undef, undef, $mode, undef, undef, + undef, undef, undef, undef, undef, + undef, undef, undef) = stat('t/tmp/ikiwiki.cgi'); + is ($mode & 07777, 0751); + (undef, undef, $mode, undef, undef, + undef, undef, undef, undef, undef, + undef, undef, undef) = stat('t/tmp/in/.git/hooks/post-commit'); + is ($mode & 07777, 0754); +} + +sub run_cgi { + my (%args) = @_; + my ($in, $out); + my $method = $args{method} || 'GET'; + my $environ = $args{environ} || {}; + my $params = $args{params} || { do => 'prefs' }; + + my %defaults = ( + SCRIPT_NAME => '/cgi-bin/ikiwiki.cgi', + HTTP_HOST => 'example.com', + ); + + my $cgi = CGI->new($args{params}); + my $query_string = $cgi->query_string(); + + if ($method eq 'POST') { + $defaults{REQUEST_METHOD} = 'POST'; + $in = $query_string; + $defaults{CONTENT_LENGTH} = length $in; + } else { + $defaults{REQUEST_METHOD} = 'GET'; + $defaults{QUERY_STRING} = $query_string; + } + + my %envvars = ( + %defaults, + %$environ, + ); + run(["./t/tmp/ikiwiki.cgi"], \$in, \$out, init => sub { + map { + $ENV{$_} = $envvars{$_} + } keys(%envvars); + }); + + return $out; +} + +sub run_git { + my (undef, $filename, $line) = caller; + my $args = shift; + my $desc = shift || join(' ', 'git', @$args); + my ($in, $out); + ok(run(['git', @$args], \$in, \$out, init => sub { + chdir 't/tmp/in' or die $!; + $ENV{EMAIL} = 'nobody@ikiwiki-tests.invalid'; + }), "$desc at $filename:$line"); + return $out; +} + +sub test { + my $content; + my $status; + + ok(! system(qw(rm -rf t/tmp))); + ok(! system(qw(mkdir t/tmp))); + + write_old_file('.gitignore', 't/tmp/in', "/doc/.ikiwiki/\n"); + write_old_file('doc/writable/one.mdwn', 't/tmp/in', 'This is the first test page'); + write_old_file('doc/writable/two.mdwn', 't/tmp/in', 'This is the second test page'); + write_old_file('doc/writable/three.mdwn', 't/tmp/in', 'This is the third test page'); + + unless ($installed) { + ok(! system(qw(cp -pRL doc/wikiicons t/tmp/in/doc/))); + ok(! system(qw(cp -pRL doc/recentchanges.mdwn t/tmp/in/doc/))); + } + + run_git(['init']); + run_git(['add', '.']); + run_git(['commit', '-m', 'Initial commit']); + + write_setup_file(); + thoroughly_rebuild(); + check_cgi_mode_bits(); + + ok(-e 't/tmp/out/writable/one/index.html'); + $content = readfile('t/tmp/out/writable/one/index.html'); + like($content, qr{This is the first test page}); + my $orig_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + + # Test the git hook, which accepts git commits + writefile('doc/writable/one.mdwn', 't/tmp/in', + 'This is new content for the first test page'); + run_git(['add', '.']); + run_git(['commit', '-m', 'Git commit']); + my $first_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + isnt($orig_sha1, $first_revertable_sha1); + + ok(-e 't/tmp/out/writable/one/index.html'); + $content = readfile('t/tmp/out/writable/one/index.html'); + like($content, qr{This is new content for the first test page}); + + # Test a web commit + $content = run_cgi(method => 'POST', + params => { + do => 'edit', + page => 'writable/two', + type => 'mdwn', + editmessage => 'Web commit', + editcontent => 'Here is new content for the second page', + _submit => 'Save Page', + _submitted => '1', + }, + ); + like($content, qr{^Status:\s*302\s}m); + like($content, qr{^Location:\s*http://example\.com/writable/two/\?updated}m); + my $second_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + isnt($orig_sha1, $second_revertable_sha1); + isnt($first_revertable_sha1, $second_revertable_sha1); + + ok(-e 't/tmp/out/writable/two/index.html'); + $content = readfile('t/tmp/out/writable/two/index.html'); + like($content, qr{Here is new content for the second page}); + + # Another edit + writefile('doc/writable/three.mdwn', 't/tmp/in', + 'Also new content for the third page'); + run_git(['add', '.']); + run_git(['commit', '-m', 'Git commit']); + ok(-e 't/tmp/out/writable/three/index.html'); + $content = readfile('t/tmp/out/writable/three/index.html'); + like($content, qr{Also new content for the third page}); + my $third_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + isnt($orig_sha1, $third_revertable_sha1); + isnt($second_revertable_sha1, $third_revertable_sha1); + + run_git(['mv', 'doc/writable/one.mdwn', 'doc/one.mdwn']); + run_git(['mv', 'doc/writable/two.mdwn', 'two.mdwn']); + run_git(['commit', '-m', 'Rename files to test CVE-2016-10026']); + ok(! -e 't/tmp/out/writable/two/index.html'); + ok(! -e 't/tmp/out/writable/one/index.html'); + ok(-e 't/tmp/out/one/index.html'); + my $sha1_before_revert = run_git(['rev-list', '--max-count=1', 'HEAD']); + isnt($sha1_before_revert, $third_revertable_sha1); + + $content = run_cgi(method => 'post', + params => { + do => 'revert', + revertmessage => 'CVE-2016-10026', + rev => $first_revertable_sha1, + _submit => 'Revert', + _submitted_revert => '1', + }, + ); + like($content, qr{is locked and cannot be edited}); + # The tree is left clean + run_git(['diff', '--exit-code']); + run_git(['diff', '--cached', '--exit-code']); + my $sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + is($sha1, $sha1_before_revert); + + ok(-e 't/tmp/out/one/index.html'); + ok(! -e 't/tmp/in/doc/writable/one.mdwn'); + ok(-e 't/tmp/in/doc/one.mdwn'); + $content = readfile('t/tmp/out/one/index.html'); + like($content, qr{This is new content for the first test page}); + + $content = run_cgi(method => 'post', + params => { + do => 'revert', + revertmessage => 'CVE-2016-10026', + rev => $second_revertable_sha1, + _submit => 'Revert', + _submitted_revert => '1', + }, + ); + like($content, qr{you are not allowed to change two\.mdwn}); + run_git(['diff', '--exit-code']); + run_git(['diff', '--cached', '--exit-code']); + $sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']); + is($sha1, $sha1_before_revert); + + ok(! -e 't/tmp/out/writable/two/index.html'); + ok(! -e 't/tmp/out/two/index.html'); + ok(! -e 't/tmp/in/doc/writable/two.mdwn'); + ok(-e 't/tmp/in/two.mdwn'); + $content = readfile('t/tmp/in/two.mdwn'); + like($content, qr{Here is new content for the second page}); + + # This one can legitimately be reverted + $content = run_cgi(method => 'post', + params => { + do => 'revert', + revertmessage => 'not CVE-2016-10026', + rev => $third_revertable_sha1, + _submit => 'Revert', + _submitted_revert => '1', + }, + ); + like($content, qr{^Status:\s*302\s}m); + like($content, qr{^Location:\s*http://example\.com/recentchanges/}m); + run_git(['diff', '--exit-code']); + run_git(['diff', '--cached', '--exit-code']); + ok(-e 't/tmp/out/writable/three/index.html'); + $content = readfile('t/tmp/out/writable/three/index.html'); + like($content, qr{This is the third test page}); +} + +test(); + +done_testing();