From 07495a0b4ebf25f73b1bf403e90d5dc3c94c7642 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Sat, 11 Apr 2026 06:19:24 +0000 Subject: [PATCH] fix: enforce POSIX sticky bit (S_ISVTX) on unlink, rmdir, and rename POSIX requires that when a directory has the sticky bit set (mode 01000, e.g. /tmp with mode 1777), only the file owner, directory owner, or root can delete or rename entries. Without this check, any user with write permission on the directory could remove other users' files. Adds S_ISVTX constant, _check_sticky_bit() helper, and enforcement in __unlink, __rmdir, and __rename (both source and destination dirs). Co-Authored-By: Claude Opus 4.6 --- lib/Test/MockFile.pm | 71 +++++++++++++++++++ t/sticky_bit.t | 164 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 t/sticky_bit.t diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index 6c5d4cf..c234826 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -85,6 +85,7 @@ use constant S_IFBLK => 0060000; # block device use constant S_IFDIR => 0040000; # directory use constant S_IFCHR => 0020000; # character device use constant S_IFIFO => 0010000; # FIFO +use constant S_ISVTX => 0001000; # sticky bit =head1 SYNOPSIS @@ -783,6 +784,33 @@ sub _check_parent_perms { return _check_perms( $parent_mock, $access ); } +# _check_sticky_bit($parent_path, $file_path) +# Enforces sticky bit restriction on a directory. +# When a directory has the sticky bit (S_ISVTX / 01000) set, only the file +# owner, the directory owner, or root may remove/rename entries. +# Returns 1 if allowed, 0 if denied. +sub _check_sticky_bit { + my ( $parent_path, $file_path ) = @_; + + return 1 unless defined $_mock_uid; + return 1 if $_mock_uid == 0; # root bypasses sticky bit + + my $parent_mock = _get_file_object($parent_path); + return 1 unless $parent_mock; + + # Only applies when sticky bit is set on the parent directory + return 1 unless $parent_mock->{'mode'} & S_ISVTX; + + my $file_mock = _get_file_object($file_path); + return 1 unless $file_mock; + + # Allowed if user owns the file or owns the directory + return 1 if $_mock_uid == $file_mock->{'uid'}; + return 1 if $_mock_uid == $parent_mock->{'uid'}; + + return 0; +} + my @_tmf_callers; # Packages where autodie was active when T::MF was imported. @@ -3680,6 +3708,16 @@ sub __unlink (@) { $! = EACCES; next; } + + # Sticky bit: only file owner, dir owner, or root may remove + if ( defined $_mock_uid ) { + ( my $parent = $mock->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $parent = '/' if $parent eq ''; + if ( !_check_sticky_bit( $parent, $mock->{'path'} ) ) { + $! = EACCES; + next; + } + } $files_deleted += $mock->unlink; } } @@ -3993,6 +4031,17 @@ sub __rmdir (_) { return 0; } + # Sticky bit: only dir owner, parent dir owner, or root may remove + if ( defined $_mock_uid ) { + ( my $parent = $mock->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $parent = '/' if $parent eq ''; + if ( !_check_sticky_bit( $parent, $mock->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rmdir', @_ ); + return 0; + } + } + if ( grep { $_->exists } _files_in_dir($file) ) { $! = ENOTEMPTY; _maybe_throw_autodie( 'rmdir', @_ ); @@ -4041,6 +4090,28 @@ sub __rename ($$) { # Renaming to self is a no-op (POSIX rename(2)) return 1 if $mock_old == $mock_new; + # Sticky bit: source parent dir sticky bit restricts who can move the file + if ( defined $_mock_uid ) { + ( my $old_parent = $mock_old->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $old_parent = '/' if $old_parent eq ''; + if ( !_check_sticky_bit( $old_parent, $mock_old->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rename', @_ ); + return 0; + } + + # If destination exists and its parent has sticky bit, check that too + if ( $mock_new->exists ) { + ( my $new_parent = $mock_new->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $new_parent = '/' if $new_parent eq ''; + if ( !_check_sticky_bit( $new_parent, $mock_new->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rename', @_ ); + return 0; + } + } + } + # Can't overwrite a directory with a non-directory if ( $mock_new->exists && $mock_new->is_dir && !$mock_old->is_dir ) { $! = EISDIR; diff --git a/t/sticky_bit.t b/t/sticky_bit.t new file mode 100644 index 0000000..4d4d0f4 --- /dev/null +++ b/t/sticky_bit.t @@ -0,0 +1,164 @@ +use strict; +use warnings; + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; + +use Errno qw( EACCES ); +use Fcntl qw( S_IFDIR ); + +use Test::MockFile qw< nostrict >; + +# POSIX sticky bit (S_ISVTX / 01000) enforcement: +# When a directory has the sticky bit set, only the file owner, +# the directory owner, or root may delete/rename entries in it. +# Classic example: /tmp (mode 1777). + +# ========================================================================= +# Helpers +# ========================================================================= + +sub with_user (&@) { + my ( $code, $uid, @gids ) = @_; + Test::MockFile->set_user( $uid, @gids ); + my $ok = eval { $code->(); 1 }; + my $err = $@; + Test::MockFile->clear_user(); + die $err unless $ok; +} + +# new_dir applies umask, so we override mode directly after creation +sub make_dir { + my ( $path, %opts ) = @_; + my $mode = delete $opts{mode} // 0755; + my $uid = delete $opts{uid} // 0; + my $gid = delete $opts{gid} // 0; + my $dir = Test::MockFile->new_dir( $path, {} ); + $dir->{'mode'} = $mode | S_IFDIR; + $dir->{'uid'} = $uid; + $dir->{'gid'} = $gid; + return $dir; +} + +# ========================================================================= +# unlink in sticky directory +# ========================================================================= + +subtest 'unlink: sticky bit blocks non-owner deletion' => sub { + my $dir = make_dir( '/sticky', mode => 01777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/sticky/owned_by_1000', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( !unlink('/sticky/owned_by_1000'), 'unlink fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'unlink: file owner can delete in sticky dir' => sub { + my $dir = make_dir( '/sticky_owner', mode => 01777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/sticky_owner/myfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_owner/myfile'), 'file owner can unlink in sticky dir' ); + } 1000, 1000; +}; + +subtest 'unlink: directory owner can delete in sticky dir' => sub { + my $dir = make_dir( '/sticky_dirowner', mode => 01777, uid => 500, gid => 500 ); + my $file = Test::MockFile->file( '/sticky_dirowner/otherfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_dirowner/otherfile'), 'dir owner can unlink in sticky dir' ); + } 500, 500; +}; + +subtest 'unlink: root bypasses sticky bit' => sub { + my $dir = make_dir( '/sticky_root', mode => 01777, uid => 500, gid => 500 ); + my $file = Test::MockFile->file( '/sticky_root/rootfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_root/rootfile'), 'root can unlink in sticky dir' ); + } 0, 0; +}; + +subtest 'unlink: non-sticky directory allows any writer to delete' => sub { + my $dir = make_dir( '/nonsticky', mode => 0777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/nonsticky/anyfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/nonsticky/anyfile'), 'any user can unlink in non-sticky writable dir' ); + } 2000, 2000; +}; + +# ========================================================================= +# rmdir in sticky directory +# ========================================================================= + +subtest 'rmdir: sticky bit blocks non-owner removal' => sub { + my $parent = make_dir( '/sticky_rmdir', mode => 01777, uid => 0, gid => 0 ); + my $child = Test::MockFile->new_dir( '/sticky_rmdir/subdir', { mode => 0755, uid => 1000, gid => 1000 } ); + + with_user { + ok( !rmdir('/sticky_rmdir/subdir'), 'rmdir fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rmdir: entry owner can remove in sticky parent' => sub { + my $parent = make_dir( '/sticky_rmdir2', mode => 01777, uid => 0, gid => 0 ); + my $child = Test::MockFile->new_dir( '/sticky_rmdir2/subdir', { mode => 0755, uid => 1000, gid => 1000 } ); + + with_user { + ok( rmdir('/sticky_rmdir2/subdir'), 'entry owner can rmdir in sticky dir' ); + } 1000, 1000; +}; + +# ========================================================================= +# rename in sticky directory +# ========================================================================= + +subtest 'rename: sticky bit blocks non-owner rename (source)' => sub { + my $dir = make_dir( '/sticky_ren', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/sticky_ren/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren/dst', undef ); + + with_user { + ok( !rename( '/sticky_ren/src', '/sticky_ren/dst' ), 'rename fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rename: file owner can rename in sticky dir' => sub { + my $dir = make_dir( '/sticky_ren2', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/sticky_ren2/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren2/dst', undef ); + + with_user { + ok( rename( '/sticky_ren2/src', '/sticky_ren2/dst' ), 'file owner can rename in sticky dir' ); + } 1000, 1000; +}; + +subtest 'rename: sticky bit on destination blocks overwrite by non-owner' => sub { + my $srcdir = make_dir( '/ren_src', mode => 0777, uid => 0, gid => 0 ); + my $dstdir = make_dir( '/ren_dst', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/ren_src/file', 'new', { mode => 0644, uid => 2000, gid => 2000 } ); + my $dst = Test::MockFile->file( '/ren_dst/file', 'old', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( !rename( '/ren_src/file', '/ren_dst/file' ), 'rename blocked by sticky bit on destination dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rename: root bypasses sticky bit' => sub { + my $dir = make_dir( '/sticky_ren3', mode => 01777, uid => 500, gid => 500 ); + my $src = Test::MockFile->file( '/sticky_ren3/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren3/dst', undef ); + + with_user { + ok( rename( '/sticky_ren3/src', '/sticky_ren3/dst' ), 'root can rename in sticky dir' ); + } 0, 0; +}; + +done_testing();