Avoid TOCTOU race when serving console assets - #11090
Open
david-crespo wants to merge 2 commits into
Open
Conversation
david-crespo
commented
Aug 14, 2026
| InlineErrorChain::new(&e) | ||
| )) | ||
| })?; | ||
| let file = File::from_std(file); |
Contributor
Author
There was a problem hiding this comment.
The tokio docs recommend against blocking like this ("This line could block. It is not recommended to do this on the Tokio runtime."), but we were already doing it before. Not sure if it's worth wrapping the find_file calls in spawn_blocking.
What the spawn_blocking change would look like
diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs
index d6c84ec88e..24b86ead61 100644
--- a/nexus/src/external_api/console_api.rs
+++ b/nexus/src/external_api/console_api.rs
@@ -374,15 +374,18 @@
.get(http::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
- let file = match accept_gz(accept_encoding)
- .then(|| find_file(&with_gz_ext(&path), static_dir))
- {
- Some(Ok(gzipped_file)) => {
+ let gzipped_file = if accept_gz(accept_encoding) {
+ find_file_async(with_gz_ext(&path), static_dir.to_owned()).await.ok()
+ } else {
+ None
+ };
+ let file = match gzipped_file {
+ Some(gzipped_file) => {
resp = resp
.header(http::header::CONTENT_ENCODING, CONTENT_ENCODING_GZIP);
gzipped_file
}
- _ => find_file(&path, static_dir)?,
+ None => find_file_async(path.to_owned(), static_dir.to_owned()).await?,
};
let file = File::from_std(file);
@@ -439,6 +442,19 @@
HttpError::for_not_found(None, internal_msg.to_string())
}
+/// Run [`find_file`] on the blocking thread pool so its synchronous open
+/// syscalls don't block the runtime thread.
+async fn find_file_async(
+ path: Utf8PathBuf,
+ root_dir: Utf8PathBuf,
+) -> Result<std::fs::File, HttpError> {
+ tokio::task::spawn_blocking(move || find_file(&path, &root_dir))
+ .await
+ .map_err(|e| {
+ HttpError::for_internal_error(format!("error finding file: {e}"))
+ })?
+}
+
/// Open `path` beneath `root_dir` without following symlinks. Reject paths
/// containing anything other than normal segments (e.g., `..` or a leading
/// `/`). Dropshot is expected to have rejected those already, but we don't
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #927
I was cleaning up old issues linked to the prod eng huddle and figured I could knock this out. This is out of my area to feel confident in on my own, but the code, tests, and summary pass the sniff test for me. Basically this does what Dave recommended in #927 (comment), blocking symlinks segment by segment rather than checking for them once up front.
🤖 summary
find_fileused to walk the requested path doing asymlink_metadatacheck on each component, then return aUtf8PathBufthat a laterFile::openre-resolved from scratch — so every check could be invalidated between check and use. Nowfind_filewalks the path withopenatand returns the open file. Nothing is ever re-resolved by pathname: every property we care about is either enforced by the kernel atomically at open time or checked byfstaton the already-open fd.Each component is opened with
openat(dirfd, segment, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)relative to the directory fd from the previous step. Since eachopenatgets exactly one component,O_NOFOLLOWapplies to every component: a symlink at any position fails the open. The no-symlink check and the open are the same syscall, so there is no window to swap in a symlink after the check. illumos documents the failure as ELOOP, same as Linux (macOS reports ENOTDIR whenO_DIRECTORYis also set; either way we 404):https://github.com/illumos/illumos-gate/blob/a293484/usr/src/man/man2/open.2#L292-L298
Once a directory is open, renaming it doesn't matter: the next
openatresolves through the fd, which is pinned to the inode. The scenario from the issue —bpasses the check, then is swapped for a symlink beforec/dresolve — now just means we keep reading from the originalb, which we hold open.Intermediate components also pass
O_DIRECTORY, so "must be a directory" is likewise enforced by the kernel at open time rather than by a separate stat.find_filenow validates segments itself: it iteratesUtf8Path::components()and 404s anything that isn't aNormalcomponent. This matters more than it used to, because a..reachingopenatwould escape the root and an absolute path would ignore the dirfd entirely. Dropshot rejects..before we get here (covered by existing integration tests), butfind_fileno longer assumes it.The final component gets
O_NONBLOCKso the open can't hang if the path names a FIFO; it's a no-op for regular files.The new test
test_find_file_race_does_not_escape_rootdemonstrates the fix directly: open a file viafind_file, replace its parent directory with a symlink pointing outside the static root, then read. The returned handle yields the original contents; the old code, re-opening the checked path, followed the swapped symlink out of the root and fails this test.