Information Disclosure
Description
This commit introduces a runtime guard to panic when a top-level TurboTask attempts an eventually-consistent read. The intent is to prevent leaking incomplete or partially-resolved state by disallowing top-level reads that rely on eventually-consistent reads. It also introduces a SUPPRESS_EVENTUAL_CONSISTENCY_TOP_LEVEL_TASK_CHECK flag and several operation wrappers to ensure reads at the top level are strongly consistent. In effect, this mitigates a class of information-disclosure timing issues where top-level code could observe partial computations. This appears to be a genuine vulnerability fix addressing information leakage through timing/inconsistent reads, rather than a mere cleanup or dependency bump. The changes are centered on enforcing stronger consistency for top-level reads rather than changing external APIs.
Proof of Concept
Note: This PoC is conceptual and tailored to illustrate the information-disclosure risk addressed by the fix; it is not a weaponizable exploit against a running system.
Setup (conceptual TurboTasks environment):
- A top-level task launches a long-running computation that reveals a secret value only after several micro-tasks complete.
- The secret is progressively written to a shared cell (e.g., secret-cell) by worker tasks.
- A separate top-level task reads secret-cell using the default eventually-consistent read semantics.
Before the fix (v16.2.1 and earlier):
- The top-level read could observe an in-progress state or a partially computed value, effectively leaking partial information about the secret depending on timing.
- The attacker could observe different partial values across runs or get transient data that correlates with the computation progress.
After the fix (v16.2.2+):
- The patch panics or otherwise blocks top-level reads that rely on eventually-consistent state, preventing exposure of in-progress data.
Pseudo-code (illustrative, not runnable):
// 1) Start a long-running computation that writes a secret value gradually
startLongRunningTask("secret-42");
// 2) Top-level code attempts to read the partially-computed value
let observed = readCellEventuallyConsistent("secret-cell");
console.log("observed:", observed);
// Expected behavior after the fix:
// - The read would be rejected/panicked due to top-level eventual-consistency risk, avoiding leakage of partial data.
Notes:
- This PoC focuses on the information-disclosure risk tied to timing/consistency rather than a direct code-path exploit. Real-world exploitation would require a scenario where a top-level task’s read of in-progress data could be observed by an attacker or leaked through logs, traces, or UI. The fix prevents such top-level reads from proceeding when they rely on eventually-consistent data.
Commit Details
Author: Benjamin Woodruff
Date: 2026-02-27 00:14 UTC
Message:
Turbopack: Panic if a top-level task attempts an eventually consistent read (#89735)
We've had some really bad bugs in the past, which can be hard to root-cause due to eventually-consistent reads a the top-level, e.g. https://github.com/vercel/next.js/pull/77511
Outside of tests, there's no good justification for doing an eventually-consistent read at the top level. It may leak incomplete computations, which can contain errors that would otherwise be resolved by a strongly consistent read.
The basic idea here is simple:
- Store a flag in `CURRENT_TASK_STATE` indicating that we're in a top-level task.
- When reading a task output or cell, check for that flag.
But, there are a few complications:
- `RawVc`'s read and resolution logic is wrong, and internally uses multiple eventually consistent operations after a strongly consistent read.
- For this to be correct, it needs to happen as one atomic operation inside of the task backend, but it would change the backend API contract, so it's not straightforward to do that.
- I added a `SUPPRESS_EVENTUAL_CONSISTENCY_TOP_LEVEL_TASK_CHECK` flag that I'm using for now.
- There are a ton of places that are doing eventually consistent reads for no good reason. These aren't causing problems today, but we should fix them instead of suppressing them. Claude helped with this, but it's still a lot.
- Some tests actually do need eventually-consistent reads because they're testing some internal behavior of turbo-tasks. I added an intentionally long-named `unmark_top_level_task_may_leak_eventually_consistent_state` helper for this.
I manually tested `turbopack-cli` against our bundler-benchmark repository that uses it.
Triage Assessment
Vulnerability Type: Information Disclosure
Confidence: MEDIUM
Reasoning:
The change introduces a panic when a top-level turbo-task reads data using eventually-consistent reads, to prevent leaking incomplete computations or partially-resolved state. This mitigates potential information disclosure or inconsistent/security-sensitive state exposure, addressing a class of timing/information leakage vulnerabilities. Changes are focused on enforcing stronger consistency and avoiding unsafe reads at top level.
Verification Assessment
Vulnerability Type: Information Disclosure
Confidence: MEDIUM
Affected Versions: Versions prior to 16.2.2 (pre-16.2.2), i.e., up to 16.2.1
Code Diff
diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs
index 10631ab5250533..7e206cafad96f7 100644
--- a/crates/next-api/src/project.rs
+++ b/crates/next-api/src/project.rs
@@ -291,10 +291,8 @@ impl DebugBuildPathsRouteKeys {
Serialize,
Deserialize,
Clone,
- TaskInput,
PartialEq,
Eq,
- Hash,
TraceRawVcs,
NonLocalValue,
OperationValue,
@@ -458,8 +456,8 @@ pub struct ProjectContainer {
#[turbo_tasks::value_impl]
impl ProjectContainer {
- #[turbo_tasks::function]
- pub fn new(name: RcStr, dev: bool) -> Result<Vc<Self>> {
+ #[turbo_tasks::function(operation)]
+ pub fn new_operation(name: RcStr, dev: bool) -> Result<Vc<Self>> {
Ok(ProjectContainer {
name,
// we only need to enable versioning in dev mode, since build
@@ -566,10 +564,18 @@ fn define_env_diff_report(old: &DefineEnv, new: &DefineEnv) -> String {
}
impl ProjectContainer {
- pub async fn initialize(self: ResolvedVc<Self>, options: ProjectOptions) -> Result<()> {
+ /// Set up filesystems, watchers, and construct the [`Project`] instance inside the container.
+ ///
+ /// This function is intended to be called inside of [`turbo_tasks::TurboTasks::run`], but not
+ /// part of a [`turbo_tasks::function`]. We don't want it to be possibly re-executed.
+ ///
+ /// This is an associated function instead of a method because we don't currently implement
+ /// [`std::ops::Receiver`] on [`OperationVc`].
+ pub async fn initialize(this_op: OperationVc<Self>, options: ProjectOptions) -> Result<()> {
+ let this = this_op.read_strongly_consistent().await?;
let span = tracing::info_span!(
"initialize project",
- project_name = %self.await?.name,
+ project_name = %this.name,
version = options.next_version.as_str(),
env_diff = Empty
);
@@ -577,7 +583,6 @@ impl ProjectContainer {
async move {
let watch = options.watch;
- let this = self.await?;
if let Some(old_options) = &*this.options_state.get_untracked() {
span.record(
"env_diff",
@@ -586,7 +591,15 @@ impl ProjectContainer {
}
this.options_state.set(Some(options));
- let project = self.project().to_resolved().await?;
+ #[turbo_tasks::function(operation)]
+ fn project_from_container_operation(
+ container: OperationVc<ProjectContainer>,
+ ) -> Vc<Project> {
+ container.connect().project()
+ }
+ let project = project_from_container_operation(this_op)
+ .resolve_strongly_consistent()
+ .await?;
let project_fs = project_fs_operation(project)
.read_strongly_consistent()
.await?;
@@ -612,7 +625,7 @@ impl ProjectContainer {
.await
}
- pub async fn update(self: Vc<Self>, options: PartialProjectOptions) -> Result<()> {
+ pub async fn update(self: ResolvedVc<Self>, options: PartialProjectOptions) -> Result<()> {
let span = tracing::info_span!(
"update project options",
project_name = %self.await?.name,
@@ -620,6 +633,20 @@ impl ProjectContainer {
);
let span_clone = span.clone();
async move {
+ // HACK: `update` is called from a top-level function. Top-level functions are not
+ // allowed to perform eventually consistent reads. Create a stub operation
+ // to upgrade the `ResolvedVc` to an `OperationVc`. This is mostly okay
+ // because we can assume the `ProjectContainer` was originally resolved with
+ // strong consistency, and is rarely updated.
+ #[turbo_tasks::function(operation)]
+ fn project_container_operation_hack(
+ container: ResolvedVc<ProjectContainer>,
+ ) -> Vc<ProjectContainer> {
+ *container
+ }
+ let this = project_container_operation_hack(self)
+ .read_strongly_consistent()
+ .await?;
let PartialProjectOptions {
root_path,
project_path,
@@ -637,9 +664,6 @@ impl ProjectContainer {
debug_build_paths,
} = options;
- let resolved_self = self.to_resolved().await?;
- let this = resolved_self.await?;
-
let mut new_options = this
.options_state
.get()
@@ -692,7 +716,7 @@ impl ProjectContainer {
// TODO: Handle mode switch, should prevent mode being switched.
let watch = new_options.watch;
- let project = project_operation(resolved_self)
+ let project = project_operation(self)
.resolve_strongly_consistent()
.await?;
let prev_project_fs = project_fs_operation(project)
@@ -710,7 +734,7 @@ impl ProjectContainer {
);
}
this.options_state.set(Some(new_options));
- let project = project_operation(resolved_self)
+ let project = project_operation(self)
.resolve_strongly_consistent()
.await?;
let project_fs = project_fs_operation(project)
diff --git a/crates/next-build-test/src/lib.rs b/crates/next-build-test/src/lib.rs
index 097286d00533a8..e2de9d14ce5073 100644
--- a/crates/next-build-test/src/lib.rs
+++ b/crates/next-build-test/src/lib.rs
@@ -8,6 +8,7 @@ use std::{str::FromStr, time::Instant};
use anyhow::{Context, Result};
use futures_util::{StreamExt, TryStreamExt};
use next_api::{
+ entrypoints::Entrypoints,
project::{HmrTarget, ProjectContainer, ProjectOptions},
route::{Endpoint, EndpointOutputPaths, Route, endpoint_write_to_disk},
};
@@ -39,15 +40,25 @@ pub async fn main_inner(
let project = tt
.run(async {
- let project = ProjectContainer::new(rcstr!("next-build-test"), options.dev);
- let project = project.to_resolved().await?;
- project.initialize(options).await?;
- Ok(project)
+ let container_op = ProjectContainer::new_operation(rcstr!("next.js"), options.dev);
+ ProjectContainer::initialize(container_op, options).await?;
+ container_op.resolve_strongly_consistent().await
})
.await?;
tracing::info!("collecting endpoints");
- let entrypoints = tt.run(async move { project.entrypoints().await }).await?;
+
+ #[turbo_tasks::function(operation)]
+ fn project_entrypoints_operation(project: ResolvedVc<ProjectContainer>) -> Vc<Entrypoints> {
+ project.entrypoints()
+ }
+ let entrypoints = tt
+ .run(async move {
+ project_entrypoints_operation(project)
+ .read_strongly_consistent()
+ .await
+ })
+ .await?;
let mut routes = if let Some(files) = files {
tracing::info!("building only the files:");
@@ -84,7 +95,7 @@ pub async fn main_inner(
}
if matches!(strategy, Strategy::Development { .. }) {
- hmr(tt, *project).await?;
+ hmr(tt, project).await?;
}
Ok(())
@@ -252,13 +263,24 @@ pub fn endpoint_write_to_disk_operation(
async fn hmr(
tt: &TurboTasks<TurboTasksBackend<NoopBackingStorage>>,
- project: Vc<ProjectContainer>,
+ project: ResolvedVc<ProjectContainer>,
) -> Result<()> {
tracing::info!("HMR...");
let session = TransientInstance::new(());
+
+ #[turbo_tasks::function(operation)]
+ fn project_hmr_chunk_names_operation(project: ResolvedVc<ProjectContainer>) -> Vc<Vec<RcStr>> {
+ project.hmr_chunk_names(HmrTarget::Client)
+ }
+
let idents = tt
- .run(async move { project.hmr_chunk_names(HmrTarget::Client).await })
+ .run(async move {
+ project_hmr_chunk_names_operation(project)
+ .read_strongly_consistent()
+ .await
+ })
.await?;
+
let start = Instant::now();
for ident in idents {
if !ident.ends_with(".js") {
diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs
index 3054084799500a..415266faf00aa7 100644
--- a/crates/next-napi-bindings/src/next_api/project.rs
+++ b/crates/next-napi-bindings/src/next_api/project.rs
@@ -1,4 +1,11 @@
-use std::{borrow::Cow, io::Write, path::PathBuf, sync::Arc, thread, time::Duration};
+use std::{
+ borrow::Cow,
+ io::Write,
+ path::{Path, PathBuf},
+ sync::Arc,
+ thread,
+ time::Duration,
+};
use anyhow::{Context, Result, anyhow, bail};
use bincode::{Decode, Encode};
@@ -47,8 +54,7 @@ use turbo_tasks::{
};
use turbo_tasks_backend::{BackingStorage, db_invalidation::invalidation_reasons};
use turbo_tasks_fs::{
- DiskFileSystem, FileContent, FileSystem, FileSystemPath, invalidation,
- to_sys_path as fs_path_to_sys_path, util::uri_from_file,
+ DiskFileSystem, FileContent, FileSystem, FileSystemPath, invalidation, util::uri_from_file,
};
use turbo_unix_path::{get_relative_path_to, sys_to_unix, unix_to_sys};
use turbopack_core::{
@@ -553,14 +559,14 @@ pub fn project_new(
});
}
- let options: ProjectOptions = options.into();
+ let options = ProjectOptions::from(options);
let is_dev = options.dev;
+ let root_path = options.root_path.clone();
let container = turbo_tasks
.run(async move {
- let project = ProjectContainer::new(rcstr!("next.js"), is_dev);
- let project = project.to_resolved().await?;
- project.initialize(options).await?;
- Ok(project)
+ let container_op = ProjectContainer::new_operation(rcstr!("next.js"), is_dev);
+ ProjectContainer::initialize(container_op, options).await?;
+ container_op.resolve_strongly_consistent().await
})
.or_else(|e| turbopack_ctx.throw_turbopack_internal_result(&e.into()))
.await?;
@@ -568,15 +574,26 @@ pub fn project_new(
if is_dev {
Handle::current().spawn({
let tt = turbo_tasks.clone();
+ let root_path = root_path.clone();
async move {
let result = tt
.clone()
.run(async move {
- benchmark_file_io(
- tt,
- container.project().node_root().owned().await?,
- )
- .await
+ #[turbo_tasks::function(operation)]
+ fn project_node_root_path_operation(
+ container: ResolvedVc<ProjectContainer>,
+ ) -> Vc<FileSystemPath> {
+ container.project().node_root()
+ }
+
+ let mut absolute_benchmark_dir = PathBuf::from(root_path);
+ absolute_benchmark_dir.push(
+ &project_node_root_path_operation(container)
+ .read_strongly_consistent()
+ .await?
+ .path,
+ );
+ benchmark_file_io(&tt, &absolute_benchmark_dir).await
})
.await;
if let Err(err) = result {
@@ -635,16 +652,8 @@ impl CompilationEvent for SlowFilesystemEvent {
/// This idea is copied from Bun:
/// - https://x.com/jarredsumner/status/1637549427677364224
/// - https://github.com/oven-sh/bun/blob/06a9aa80c38b08b3148bfeabe560/src/install/install.zig#L3038
-async fn benchmark_file_io(turbo_tasks: NextTurboTasks, directory: FileSystemPath) -> Result<()> {
- // try to get the real file path on disk so that we can use it with tokio
- let fs = ResolvedVc::try_downcast_type::<DiskFileSystem>(directory.fs)
- .context(anyhow!(
- "expected node_root to be a DiskFileSystem, cannot benchmark"
- ))?
- .await?;
-
- let directory = fs.to_sys_path(&directory);
- let temp_path = directory.join(format!(
+async fn benchmark_file_io(turbo_tasks: &NextTurboTasks, dir: &Path) -> Result<()> {
+ let temp_path = dir.join(format!(
"tmp_file_io_benchmark_{:x}",
rand::random::<u128>()
));
@@ -675,7 +684,7 @@ async fn benchmark_file_io(turbo_tasks: NextTurboTasks, directory: FileSystemPat
let duration = Instant::now().duration_since(start);
if duration > SLOW_FILESYSTEM_THRESHOLD {
turbo_tasks.send_compilation_event(Arc::new(SlowFilesystemEvent {
- directory: directory.to_string_lossy().into(),
+ directory: dir.to_string_lossy().into(),
duration_ms: duration.as_millis(),
}));
}
@@ -692,11 +701,9 @@ pub async fn project_update(
let ctx = &project.turbopack_ctx;
let options = options.into();
let container = project.container;
+
ctx.turbo_tasks()
- .run(async move {
- container.update(options).await?;
- Ok(())
- })
+ .run(async move { container.update(options).await })
.or_else(|e| ctx.throw_turbopack_internal_result(&e.into()))
.await
}
@@ -1166,34 +1173,42 @@ async fn invalidate_deferred_entry_source_dirs_after_callback(
return Ok(());
}
- let project = container.project();
- let app_dir = find_app_dir(project.project_path().owned().await?).await?;
+ #[turbo_tasks::value(cell = "new", eq = "manual")]
+ struct ProjectInfo(Option<FileSystemPath>, DiskFileSystem);
- let Some(app_dir) = &*app_dir else {
+ #[turbo_tasks::function(operation)]
+ async fn project_info_operation(
+ container: ResolvedVc<ProjectContainer>,
+ ) -> Result<Vc<ProjectInfo>> {
+ let project = container.project();
+ let app_dir = find_app_dir(project.project_path().owned().await?)
+ .owned()
+ .await?;
+ let project_fs = project.project_fs().owned().await?;
+ Ok(ProjectInfo(app_dir, project_fs).cell())
+ }
+ let ProjectInfo(app_dir, project_fs) = &*project_info_operation(container)
+ .read_strongly_consistent()
+ .await?;
+
+ let Some(app_dir) = app_dir else {
return Ok(());
};
-
- let paths_to_invalidate =
- if let Some(app_dir_sys_path) = fs_path_to_sys_path(a
... [truncated]