From 9e96aa60329581be0a3645b5edafb44825420782 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Sun, 5 Apr 2026 21:37:13 +0000 Subject: [PATCH 1/2] test: demonstrate truncate bypasses write permission check Path-based truncate() on a read-only mocked file succeeds when it should fail with EACCES. This test proves the vulnerability before the fix. Co-Authored-By: Claude Opus 4.6 --- t/truncate.t | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/t/truncate.t b/t/truncate.t index ac806e8..54d67ca 100644 --- a/t/truncate.t +++ b/t/truncate.t @@ -7,7 +7,7 @@ use Test2::Plugin::NoWarnings; use Fcntl qw( O_RDWR O_CREAT ); use File::Temp (); -use Errno qw( ENOENT EISDIR EINVAL ); +use Errno qw( EACCES ENOENT EISDIR EINVAL ); # Create a real tempfile before loading Test::MockFile my $real_tempfile; @@ -143,4 +143,51 @@ subtest 'truncate via append filehandle succeeds' => sub { close $fh; }; +subtest 'truncate by path on read-only file fails with EACCES' => sub { + my $mock = Test::MockFile->file( + '/fake/readonly_path', 'some data', + { mode => 0444, uid => 99, gid => 99 }, + ); + + # Without mock user, permission checks are skipped — truncate succeeds + ok( truncate( '/fake/readonly_path', 4 ), 'truncate succeeds without mock user' ); + is( $mock->contents(), 'some', 'contents shortened' ); + + # Restore contents for next check + $mock->contents('some data'); + + # With mock user as non-owner, read-only file should deny truncate + Test::MockFile->set_user( 1000, 1000 ); + $! = 0; + my $ret = truncate( '/fake/readonly_path', 4 ); + ok( !$ret, 'truncate by path returns false on read-only file' ); + is( $! + 0, EACCES, '$! is EACCES' ); + is( $mock->contents(), 'some data', 'contents unchanged' ); + Test::MockFile->clear_user(); +}; + +subtest 'truncate by path succeeds when user has write permission' => sub { + my $mock = Test::MockFile->file( + '/fake/writable_path', 'writable data', + { mode => 0644, uid => 1000, gid => 1000 }, + ); + + Test::MockFile->set_user( 1000, 1000 ); + ok( truncate( '/fake/writable_path', 8 ), 'truncate succeeds on writable file' ); + is( $mock->contents(), 'writable', 'contents shortened' ); + Test::MockFile->clear_user(); +}; + +subtest 'truncate by path — root bypasses write check' => sub { + my $mock = Test::MockFile->file( + '/fake/root_trunc', 'secret data', + { mode => 0000, uid => 99, gid => 99 }, + ); + + Test::MockFile->set_user( 0, 0 ); + ok( truncate( '/fake/root_trunc', 6 ), 'root can truncate 0000 mode file' ); + is( $mock->contents(), 'secret', 'contents shortened by root' ); + Test::MockFile->clear_user(); +}; + done_testing(); From fec4ae6c42a9e829cf584a8bd52ca875af403e69 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Sun, 5 Apr 2026 21:38:22 +0000 Subject: [PATCH 2/2] fix: enforce write permission check on path-based truncate() POSIX truncate(2) requires write permission on the file when called by path. The mocked truncate only checked permission for the filehandle form (EINVAL if not open for writing) but allowed path-based truncate to succeed on read-only files. Add _check_perms($mock, 2) for the path case. Co-Authored-By: Claude Opus 4.6 --- lib/Test/MockFile.pm | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index 6c5d4cf..d7051dd 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -4434,6 +4434,14 @@ sub __truncate ($$) { return 0; } } + else { + # Path-based truncate: POSIX truncate(2) requires write permission on the file. + if ( !_check_perms( $mock, 2 ) ) { + $! = EACCES; + _maybe_throw_autodie( 'truncate', @_ ); + return 0; + } + } if ( $length < 0 ) { $! = EINVAL;