Skip to content

Commit 1ca9a53

Browse files
committed
Refuse to extract symbolic links in archives that point outside the course directory.
Currently when extracting zip or tar archives in the file manager, any symbolic link in the archive is extracted assuming the link itself is in the course directory. That is a security vulnerability as a link could point to something like `/etc` and give unsecure access to system files. So this refuses to extract symbolic links in archives that point outside the course directory. The way that this works is it actually does create the links, but then it prunes any links that point outside the course directory. This is so that `realpath` can be used, and is the only way to properly validate the links. Naively attempting to collapse `..` instances in the path does not work reliably (see the note for the `canonpath` method at https://metacpan.org/pod/File::Spec::Unix), and I was able to subvert that to achieve a functional link to outside of the course directory. Note that broken symbolic links must also be rejected. This is because `realpath` does not work for these, and so there is no way to validate them as being links that point inside the course directory. In fact, it is possible to devise a broken symbolic link that combined with a valid symbolic link in another archive file, becomes a link to a location outside of the course directory (and I did so in testing). Note this means that the only symbolic links allowed in a course are the required symbolic links (`Library`, `Contrib`, and `Student_Orientation` at this point), and those that are created by a system administrator. Also note that symbolic links in a course archive are still restored as before when unarchiving a course. It is considered the responsibility of the system administrator to validate links in a course archive. This fixes the last of the security vulnerabilities that @Alex-Jordan found. Note that this was initially coded by Claude, but I largely rewrote what Claude created.
1 parent e642132 commit 1ca9a53

1 file changed

Lines changed: 55 additions & 4 deletions

File tree

lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,21 @@ sub MakeArchive ($c) {
496496
}
497497
}
498498

499+
# Verify that symbolic links resolve, on the real filesystem, to locations inside the course directory, and remove any
500+
# that do not. This must be done after the links have already been created, since `realpath` only works in that case. A
501+
# broken link cannot be resolved with `realpath`, and so cannot be verified to point inside the course directory. It is
502+
# removed just like a link that is known to point outside of the course directory. Returns the names of the links that
503+
# are removed.
504+
sub prune_unsafe_links ($c, @links) {
505+
my @unsafe;
506+
for (@links) {
507+
next if -e $_->[1]->to_string && path_is_subdir($_->[1]->realpath->to_string, $c->{courseRoot});
508+
push(@unsafe, $_->[0]);
509+
unlink($_->[1]->to_string);
510+
}
511+
return @unsafe;
512+
}
513+
499514
# Unpack a gzipped tar archive
500515
sub UnpackArchive ($c) {
501516
my $archive = $c->getFile('unpack');
@@ -511,7 +526,7 @@ sub UnpackArchive ($c) {
511526
sub unpack_archive ($c, $archive) {
512527
my $dir = Mojo::File->new($c->{courseRoot}, $c->{pwd});
513528

514-
my (@members, @existing_files, @outside_files, @forbidden_files);
529+
my (@members, @existing_files, @outside_files, @forbidden_files, @unsafe_links);
515530
my $num_extracted = 0;
516531

517532
if ($archive =~ m/\.zip$/) {
@@ -526,6 +541,10 @@ sub unpack_archive ($c, $archive) {
526541
$c->addbadmessage($error);
527542
});
528543

544+
# Symbolic links are extracted regardless of their target, and any
545+
# links that point outside the course directory are pruned afterward.
546+
my @created_links;
547+
529548
@members = $zip->members;
530549
for (@members) {
531550
my $out_file = $dir->child($_->fileName);
@@ -546,10 +565,19 @@ sub unpack_archive ($c, $archive) {
546565
push(@existing_files, $_->fileName);
547566
next;
548567
}
549-
++$num_extracted if $zip->extractMember($_ => $out_file->to_string) == AZ_OK;
568+
569+
if ($zip->extractMember($_ => $out_file->to_string) == AZ_OK) {
570+
++$num_extracted;
571+
push(@created_links, [ $_->fileName, $out_file ]) if $_->isSymbolicLink;
572+
}
550573
}
551574

552575
Archive::Zip::setErrorHandler();
576+
577+
if (my @unsafe = $c->prune_unsafe_links(@created_links)) {
578+
push(@unsafe_links, @unsafe);
579+
$num_extracted -= @unsafe;
580+
}
553581
} elsif ($archive =~ m/\.(tar(\.gz)?|tgz)$/) {
554582
local $Archive::Tar::WARN = 0;
555583

@@ -561,6 +589,10 @@ sub unpack_archive ($c, $archive) {
561589

562590
$tar->setcwd($dir->to_string);
563591

592+
# Symbolic links are extracted regardless of their target, and any
593+
# links that point outside the course directory are pruned afterward.
594+
my @created_links;
595+
564596
@members = $tar->list_files;
565597
for (@members) {
566598
my $out_file = $dir->child($_);
@@ -584,18 +616,23 @@ sub unpack_archive ($c, $archive) {
584616

585617
my ($member) = $tar->get_files($_);
586618
if ($member && $member->is_symlink) {
587-
# Secure extract mode refuses links whose targets leave the directory;
588-
# recreate them directly (location already validated above).
619+
# Secure extract mode refuses to extract links. So recreate them directly.
589620
unless (symlink($member->linkname, $out_file->to_string)) {
590621
$c->addbadmessage($c->maketext(q{Unable to extract "[_1]": [_2]}, $_, $!));
591622
next;
592623
}
624+
push(@created_links, [ $_, $out_file ]);
593625
} elsif (!$tar->extract_file($_)) {
594626
$c->addbadmessage($tar->error);
595627
next;
596628
}
597629
++$num_extracted;
598630
}
631+
632+
if (my @unsafe = $c->prune_unsafe_links(@created_links)) {
633+
push(@unsafe_links, @unsafe);
634+
$num_extracted -= @unsafe;
635+
}
599636
} else {
600637
$c->addbadmessage($c->maketext('Unsupported archive type in file "[_1]"', $archive));
601638
return 0;
@@ -629,6 +666,20 @@ sub unpack_archive ($c, $archive) {
629666
);
630667
}
631668

669+
if (@unsafe_links) {
670+
$c->addbadmessage(
671+
$c->tag(
672+
'p',
673+
$c->maketext(
674+
'The following [plural,_1,symbolic link] in the archive [plural,_1,points,point] outside the '
675+
. 'course directory or [plural,_1,is,are] broken, and [plural,_1,was,were] not created.',
676+
scalar(@unsafe_links),
677+
)
678+
)
679+
. $c->tag('div', $c->tag('ul', $c->c((map { $c->tag('li', $_) } @unsafe_links))->join('')))
680+
);
681+
}
682+
632683
# There aren't many of these, so all of them can be reported.
633684
if (@forbidden_files) {
634685
$c->addbadmessage(

0 commit comments

Comments
 (0)