Skip to content

Commit 638b337

Browse files
authored
release-24.0.0: Fix wasmtime-wasi path_open(TRUNCATE) bypass of FilePerms::WRITE check (#13433)
* NFC: refactor wasi testing to make WasiCtxBuilder changes with closure Port of 89d1a79 to release-24.0.0 branch * fix and test GHSA-2r75-cxrj-cmph In wasmtime-wasi, when a filesystem preopen is given DirPerms::all() and FilePerms::READ without FilePerms::WRITE, this wasmtime-wasi enforced access control mechanism can be bypassed by using the wasip2 descriptor.open-at or wasip1 path_open interfaces by opening a file with OpenFlags::TRUNCATE oflag only, for example: ```rust dir_descriptor.open_at( PathFlags::empty(), FILENAME, OpenFlags::TRUNCATE, DescriptorFlags::READ, ) ``` or ```rust wasip1::path_open( dir_fd, 0, FILENAME, wasip1::OFLAGS_TRUNC, wasip1::RIGHTS_FD_READ, 0, 0 ) ``` The root cause is that the clause that considered OpenFlags::TRUNCATE did not set open_mode |= OpenMode::WRITE;, used later in that function for the access control check against FilePerms for whether opening that file is permitted. With the bug corrected, these calls to open-at and path_open fail with error-code.not-permitted and ERRNO_PERM respectively. This commit contains the fix for the above bug, and tests for the fix. * Release notes for 24.0.9: fix GHSA-2r75-cxrj-cmph * rustfmt with 1.78.0 * wasi-common: test stubs
1 parent f179d80 commit 638b337

9 files changed

Lines changed: 546 additions & 214 deletions

File tree

RELEASES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## 24.0.9
2+
3+
Unreleased.
4+
5+
### Fixed
6+
7+
* WASI path_open(TRUNCATE) bypasses `FilePerms::WRITE` host restriction.
8+
[GHSA-2r75-cxrj-cmph](https://github.com/bytecodealliance/wasmtime/security/advisories/GHSA-2r75-cxrj-cmph)
9+
10+
--------------------------------------------------------------------------------
11+
112
## 24.0.8
213

314
Released 2026-04-30.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use std::process;
2+
use test_programs::preview1::open_scratch_directory;
3+
4+
const FILENAME: &str = "test.txt";
5+
unsafe fn test_file_has_expected_contents(dir_fd: wasi::Fd) {
6+
// Open a file for reading
7+
let file_fd = wasi::path_open(dir_fd, 0, FILENAME, 0, wasi::RIGHTS_FD_READ, 0, 0)
8+
.expect("opening test.txt for reading");
9+
10+
// Read the file's contents
11+
let buffer = &mut [0u8; 100];
12+
let nread = wasi::fd_read(
13+
file_fd,
14+
&[wasi::Iovec {
15+
buf: buffer.as_mut_ptr(),
16+
buf_len: buffer.len(),
17+
}],
18+
)
19+
.expect("reading file content");
20+
21+
const EXPECTED_CONTENTS: &[u8] = b"truncation test file\n";
22+
// The file should be as created by the test harness, not truncated.
23+
assert_eq!(nread, EXPECTED_CONTENTS.len(), "expected untouched file");
24+
assert_eq!(
25+
&buffer[..nread],
26+
EXPECTED_CONTENTS,
27+
"expected untouched file contents"
28+
);
29+
30+
wasi::fd_close(file_fd).expect("closing the file");
31+
}
32+
33+
unsafe fn test_file_truncation_readonly(dir_fd: wasi::Fd) {
34+
// Check test preconditions.
35+
test_file_has_expected_contents(dir_fd);
36+
37+
// Opening the file for truncation should fail.
38+
let err = wasi::path_open(
39+
dir_fd,
40+
0,
41+
FILENAME,
42+
wasi::OFLAGS_TRUNC,
43+
wasi::RIGHTS_FD_READ,
44+
0,
45+
0,
46+
);
47+
assert!(err.is_err(), "opening file for truncation should fail");
48+
assert_eq!(
49+
err.err().unwrap(),
50+
wasi::ERRNO_PERM,
51+
"opening file for truncation should fail with PERM"
52+
);
53+
54+
// Check that truncation did not occur.
55+
test_file_has_expected_contents(dir_fd);
56+
}
57+
58+
fn main() {
59+
// This test program requires a special preopen at the path "readonly",
60+
// which the host enforces as read-only. Unlike other test programs, this
61+
// directory's path not passed in as an argument, because modifications to
62+
// the testing harness would be too invasive.
63+
let dir_fd = match open_scratch_directory("readonly") {
64+
Ok(dir_fd) => dir_fd,
65+
Err(err) => {
66+
eprintln!("{err}");
67+
process::exit(1)
68+
}
69+
};
70+
71+
// Run the tests.
72+
unsafe {
73+
test_file_truncation_readonly(dir_fd);
74+
}
75+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use test_programs::wasi::filesystem::preopens;
2+
use test_programs::wasi::filesystem::types::{
3+
Descriptor, DescriptorFlags, ErrorCode, OpenFlags, PathFlags,
4+
};
5+
6+
const FILENAME: &str = "test.txt";
7+
fn test_file_has_expected_contents(dir: &Descriptor) {
8+
// Open a file for reading
9+
let file = dir
10+
.open_at(
11+
PathFlags::empty(),
12+
FILENAME,
13+
OpenFlags::empty(),
14+
DescriptorFlags::READ,
15+
)
16+
.expect("open test.txt for reading");
17+
18+
// Read the file's contents
19+
let stream = file.read_via_stream(0).unwrap();
20+
let read = stream.blocking_read(100).expect("reading test.txt content");
21+
drop(stream);
22+
drop(file);
23+
24+
const EXPECTED_CONTENTS: &[u8] = b"truncation test file\n";
25+
// The file should not be empty due to truncation
26+
assert_eq!(read, EXPECTED_CONTENTS, "expected untouched file contents");
27+
}
28+
29+
fn test_file_truncation_readonly(dir: &Descriptor) {
30+
// Check test preconditions.
31+
test_file_has_expected_contents(dir);
32+
33+
// Opening the file for truncation should fail.
34+
let err = dir.open_at(
35+
PathFlags::empty(),
36+
FILENAME,
37+
OpenFlags::TRUNCATE,
38+
DescriptorFlags::READ,
39+
);
40+
assert!(err.is_err(), "opening file for truncation should fail");
41+
assert_eq!(
42+
err.err().unwrap(),
43+
ErrorCode::NotPermitted,
44+
"opening file for truncation should fail with ErrorCode::NotPermitted"
45+
);
46+
47+
// Check that truncation did not occur.
48+
test_file_has_expected_contents(dir);
49+
}
50+
51+
fn main() {
52+
// This test program requires a special preopen at the path "readonly",
53+
// which the host enforces as read-only. Unlike other test programs, this
54+
// directory's path not passed in as an argument, because modifications to
55+
// the testing harness would be too invasive.
56+
let preopens = preopens::get_directories();
57+
let (dir, _) = preopens
58+
.iter()
59+
.find(|(_, path)| path == "readonly")
60+
.expect("find preopen named readonly");
61+
62+
// Run the test
63+
test_file_truncation_readonly(dir);
64+
}

crates/wasi-common/tests/all/async_.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,9 @@ async fn preview1_path_open_lots() {
297297
async fn preview1_sleep_quickly_but_lots() {
298298
run(PREVIEW1_SLEEP_QUICKLY_BUT_LOTS, true).await.unwrap()
299299
}
300+
#[test]
301+
fn preview1_file_truncation_readonly() {
302+
println!(
303+
"blank placeholder test to satisfy assert_test_exists. This test exercises wasmtime-wasi functionality is not relevant to wasi-common"
304+
);
305+
}

crates/wasi-common/tests/all/sync.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,9 @@ fn preview1_path_open_lots() {
286286
fn preview1_sleep_quickly_but_lots() {
287287
run(PREVIEW1_SLEEP_QUICKLY_BUT_LOTS, true).unwrap()
288288
}
289+
#[test]
290+
fn preview1_file_truncation_readonly() {
291+
println!(
292+
"blank placeholder test to satisfy assert_test_exists. This test exercises wasmtime-wasi functionality is not relevant to wasi-common"
293+
);
294+
}

crates/wasi/src/host/filesystem.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ where
544544

545545
if oflags.contains(OpenFlags::TRUNCATE) {
546546
opts.truncate(true).write(true);
547+
open_mode |= OpenMode::WRITE;
547548
}
548549
if flags.contains(DescriptorFlags::READ) {
549550
opts.read(true);

0 commit comments

Comments
 (0)