Server-Client boundary violation / Privilege escalation
Description
This commit hardens the server-client boundary by introducing explicit error handling for invalid imports between Server Components and Client Components (e.g., server-only and client-only) via the import map. It adds a new InvalidImportIssue and wiring in the import map so that importing server-only from a Client Component (and similarly client-only from a Server Component) yields a concrete, user-friendly error rather than a brittle or ambiguous failure. This tightens security by preventing server-only code from leaking into client bundles or being misused across the server-client boundary. The change also removes some older resolve-plugin checks in favor of the centralized import-map errors, and adjusts related code paths (e.g., NextFont local fixes) to surface consistent errors. This is a hardening fix related to server-client boundary violations rather than a pure feature add or dependency bump.
Proof of Concept
PoC steps (exploitation scenario prior to this fix):
1) Create a minimal Next.js app (16.x) with an app directory and a Client Component that imports a server-only module via the standard alias used by turbopack (e.g., import { foo } from 'server-only').
2) Use a Client Component (add "use client" at the top) and attempt to import and call a server-only function from that module.
3) Run the app in dev mode with turbopack enabled.
Expected behavior before this patch: depending on the exact prior state, the client bundle could either try to resolve the server-only module in a brittle way or fail with an opaque error later in the build/run process, potentially leaking server-side details or allowing an improper server-client boundary to be inferred.
With this patch (the fix in place):
- The import map injects an explicit error alias for server-only when used from a Client Component, surfacing a clear error such as:
"'server-only' cannot be imported from a Client Component module. It should only be used from a Server Component. The error was caused by importing '<your-client-entry-point>'".
- The error is represented by an InvalidImportIssue surfaced via the new ImportMapping, providing a structured and debuggable explanation for developers.
Example client component:
// app/page.tsx
"use client";
import { serverOnlyHelper } from 'server-only'; // invalid import from a Client Component
export default function Page() {
serverOnlyHelper();
return <div>Test</div>;
}
What you should observe after the fix: the dev/build process aborts with a clear InvalidImportIssue indicating that 'server-only' cannot be imported from a Client Component module and pointing to the offending import and module path.
Commit Details
Author: Niklas Mischkulnig
Date: 2026-02-18 13:06 UTC
Message:
Turbopack: handle invalid RSC imports via importmap (#88146)
We have two lines of defense:
1. crates/next-custom-transforms/src/transforms/react_server_components.rs validates ESM imports of server-only and client-only and throws an issue if something is wrong. Notably, `require('server-only')` isn't caught by this
2. additionally, we had an `BeforeResolvePlugin` for server-only, client-only and styled-jsx.
We should probably get rid of the first one completely for consistency (it's brittle)
https://github.com/vercel/next.js/blob/79040a318d29641eb3d96a5a47c92a5f8506e826/crates/next-custom-transforms/src/transforms/react_server_components.rs#L663-L674
This PR is about improving the second one:
The error message isn't ideal yet, but better than before (note: no import trace):
```
./bench/basic-app/app
Invalid import
'server-only' cannot be imported from a Client Component module. It should only be used from a Server Component.
The error was caused by importing 'bench/basic-app/app'
```
now
<img width="708" height="386" alt="Bildschirmfoto 2026-01-07 um 17 21 59" src="https://github.com/user-attachments/assets/4d49eff3-4959-4b70-aa47-6f3c10c75588" />
Triage Assessment
Vulnerability Type: Privilege escalation / Server-Client boundary violation
Confidence: MEDIUM
Reasoning:
The commit adds explicit error handling for invalid imports between Server and Client Components (e.g., server-only and client-only). It introduces a new InvalidImportIssue and wiring in the import map to surface errors when inappropriate imports occur, tightening the server-client boundary and preventing potential misuse or information leakage. This constitutes a security-related hardening rather than a pure refactor or feature addition.
Verification Assessment
Vulnerability Type: Server-Client boundary violation / Privilege escalation
Confidence: MEDIUM
Affected Versions: < 16.2.2
Code Diff
diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs
index 19da483e9345d..8cc8e980c3de9 100644
--- a/crates/next-core/src/next_client/context.rs
+++ b/crates/next-core/src/next_client/context.rs
@@ -50,10 +50,7 @@ use crate::{
get_next_client_resolved_map,
},
next_shared::{
- resolve::{
- ModuleFeatureReportResolvePlugin, NextSharedRuntimeResolvePlugin,
- get_invalid_server_only_resolve_plugin,
- },
+ resolve::{ModuleFeatureReportResolvePlugin, NextSharedRuntimeResolvePlugin},
transforms::{
emotion::get_emotion_transform_rule,
react_remove_properties::get_react_remove_properties_transform_rule,
@@ -181,11 +178,6 @@ pub async fn get_client_resolve_options_context(
browser: true,
module: true,
before_resolve_plugins: vec![
- ResolvedVc::upcast(
- get_invalid_server_only_resolve_plugin(project_path.clone())
- .to_resolved()
- .await?,
- ),
ResolvedVc::upcast(
ModuleFeatureReportResolvePlugin::new(project_path.clone())
.to_resolved()
diff --git a/crates/next-core/src/next_edge/context.rs b/crates/next-core/src/next_edge/context.rs
index f17fa10a5d841..7810e6cee125f 100644
--- a/crates/next-core/src/next_edge/context.rs
+++ b/crates/next-core/src/next_edge/context.rs
@@ -27,10 +27,7 @@ use crate::{
next_font::local::NextFontLocalResolvePlugin,
next_import_map::{get_next_edge_and_server_fallback_import_map, get_next_edge_import_map},
next_server::context::ServerContextType,
- next_shared::resolve::{
- ModuleFeatureReportResolvePlugin, NextSharedRuntimeResolvePlugin,
- get_invalid_client_only_resolve_plugin, get_invalid_styled_jsx_resolve_plugin,
- },
+ next_shared::resolve::{ModuleFeatureReportResolvePlugin, NextSharedRuntimeResolvePlugin},
util::{
NextRuntime, OptionEnvMap, defines, foreign_code_context_condition,
free_var_references_with_vercel_system_env_warnings, worker_forwarded_globals,
@@ -130,25 +127,6 @@ pub async fn get_edge_resolve_options_context(
));
};
- if matches!(
- ty,
- ServerContextType::AppRSC { .. }
- | ServerContextType::AppRoute { .. }
- | ServerContextType::Middleware { .. }
- | ServerContextType::Instrumentation { .. }
- ) {
- before_resolve_plugins.push(ResolvedVc::upcast(
- get_invalid_client_only_resolve_plugin(project_path.clone())
- .to_resolved()
- .await?,
- ));
- before_resolve_plugins.push(ResolvedVc::upcast(
- get_invalid_styled_jsx_resolve_plugin(project_path.clone())
- .to_resolved()
- .await?,
- ));
- }
-
let after_resolve_plugins = vec![ResolvedVc::upcast(
NextSharedRuntimeResolvePlugin::new(project_path.clone())
.to_resolved()
diff --git a/crates/next-core/src/next_font/local/mod.rs b/crates/next-core/src/next_font/local/mod.rs
index 2108736fb8270..38671961c5a2b 100644
--- a/crates/next-core/src/next_font/local/mod.rs
+++ b/crates/next-core/src/next_font/local/mod.rs
@@ -10,7 +10,7 @@ use turbo_tasks_fs::{
};
use turbopack_core::{
asset::AssetContent,
- issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString},
+ issue::{Issue, IssueSeverity, IssueStage, StyledString},
reference_type::ReferenceType,
resolve::{
ResolveResult, ResolveResultItem, ResolveResultOption,
@@ -108,16 +108,12 @@ impl BeforeResolvePlugin for NextFontLocalResolvePlugin {
let font_fallbacks = &*get_font_fallbacks(lookup_path.clone(), options_vc).await?;
let font_fallbacks = match font_fallbacks {
FontFallbackResult::FontFileNotFound(err) => {
- FontResolvingIssue {
- origin_path: lookup_path.clone(),
- font_path: ResolvedVc::cell(err.0.clone()),
- }
- .resolved_cell()
- .emit();
-
return Ok(ResolveResultOption::some(
- ResolveResult::primary(ResolveResultItem::Error(ResolvedVc::cell(
- err.to_string().into(),
+ ResolveResult::primary(ResolveResultItem::Error(ResolvedVc::upcast(
+ FontResolvingIssue {
+ font_path: ResolvedVc::cell(err.0.clone()),
+ }
+ .resolved_cell(),
)))
.cell(),
));
@@ -183,16 +179,12 @@ impl BeforeResolvePlugin for NextFontLocalResolvePlugin {
let fallback = &*get_font_fallbacks(lookup_path.clone(), options).await?;
let fallback = match fallback {
FontFallbackResult::FontFileNotFound(err) => {
- FontResolvingIssue {
- origin_path: lookup_path.clone(),
- font_path: ResolvedVc::cell(err.0.clone()),
- }
- .resolved_cell()
- .emit();
-
return Ok(ResolveResultOption::some(
- ResolveResult::primary(ResolveResultItem::Error(ResolvedVc::cell(
- err.to_string().into(),
+ ResolveResult::primary(ResolveResultItem::Error(ResolvedVc::upcast(
+ FontResolvingIssue {
+ font_path: ResolvedVc::cell(err.0.clone()),
+ }
+ .resolved_cell(),
)))
.cell(),
));
@@ -320,9 +312,6 @@ fn font_file_options_from_query_map(query: &RcStr) -> Result<NextFontLocalFontFi
#[turbo_tasks::value(shared)]
struct FontResolvingIssue {
font_path: ResolvedVc<RcStr>,
- // TODO(PACK-4879): The filepath is incorrect and there should be a fine grained source
- // location pointing at the import/require
- origin_path: FileSystemPath,
}
#[turbo_tasks::value_impl]
@@ -333,7 +322,7 @@ impl Issue for FontResolvingIssue {
#[turbo_tasks::function]
fn file_path(&self) -> Vc<FileSystemPath> {
- self.origin_path.clone().cell()
+ panic!("FontResolvingIssue::file_path should not be called");
}
#[turbo_tasks::function]
diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs
index a734c931f9845..c53b88994b15a 100644
--- a/crates/next-core/src/next_import_map.rs
+++ b/crates/next-core/src/next_import_map.rs
@@ -7,7 +7,7 @@ use turbo_rcstr::{RcStr, rcstr};
use turbo_tasks::{FxIndexMap, ResolvedVc, Vc, fxindexmap};
use turbo_tasks_fs::{FileSystem, FileSystemPath, to_sys_path};
use turbopack_core::{
- issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString},
+ issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString},
reference_type::{CommonJsReferenceSubType, ReferenceType},
resolve::{
AliasPattern, ExternalTraced, ExternalType, ResolveAliasMap, SubpathValue,
@@ -280,6 +280,8 @@ pub async fn get_next_client_import_map(
insert_turbopack_dev_alias(&mut import_map).await?;
insert_instrumentation_client_alias(&mut import_map, project_path).await?;
+ insert_server_only_error_alias(&mut import_map);
+
Ok(import_map.cell())
}
@@ -551,6 +553,16 @@ pub async fn get_next_edge_import_map(
}
}
+ if matches!(
+ ty,
+ ServerContextType::AppRSC { .. }
+ | ServerContextType::AppRoute { .. }
+ | ServerContextType::Middleware { .. }
+ | ServerContextType::Instrumentation { .. }
+ ) {
+ insert_client_only_error_alias(&mut import_map);
+ }
+
Ok(import_map.cell())
}
@@ -704,9 +716,7 @@ async fn insert_next_server_special_aliases(
match &ty {
ServerContextType::Pages { .. } | ServerContextType::PagesApi { .. } => {}
// the logic closely follows the one in createRSCAliases in webpack-config.ts
- ServerContextType::AppSSR { app_dir }
- | ServerContextType::AppRSC { app_dir, .. }
- | ServerContextType::AppRoute { app_dir, .. } => {
+ ServerContextType::AppSSR { app_dir } => {
let next_package = get_next_package(app_dir.clone()).await?;
import_map.insert_exact_alias(
rcstr!("styled-jsx"),
@@ -726,7 +736,10 @@ async fn insert_next_server_special_aliases(
)
.await?;
}
- ServerContextType::Middleware { .. } | ServerContextType::Instrumentation { .. } => {
+ ServerContextType::AppRSC { .. }
+ | ServerContextType::AppRoute { .. }
+ | ServerContextType::Middleware { .. }
+ | ServerContextType::Instrumentation { .. } => {
rsc_aliases(
import_map,
project_path.clone(),
@@ -766,11 +779,11 @@ async fn insert_next_server_special_aliases(
project_path.clone(),
fxindexmap! {
rcstr!("server-only") => rcstr!("next/dist/compiled/server-only/empty"),
- rcstr!("client-only") => rcstr!("next/dist/compiled/client-only/error"),
rcstr!("next/dist/compiled/server-only") => rcstr!("next/dist/compiled/server-only/empty"),
rcstr!("next/dist/compiled/client-only") => rcstr!("next/dist/compiled/client-only/error"),
},
);
+ insert_client_only_error_alias(import_map);
}
ServerContextType::AppSSR { .. } => {
insert_exact_alias_map(
@@ -1458,6 +1471,113 @@ async fn insert_instrumentation_client_alias(
Ok(())
}
+fn insert_client_only_error_alias(import_map: &mut ImportMap) {
+ import_map.insert_exact_alias(
+ rcstr!("client-only"),
+ ImportMapping::Error(ResolvedVc::upcast(
+ InvalidImportIssue {
+ title: StyledString::Line(vec![
+ StyledString::Code(rcstr!("'client-only'")),
+ StyledString::Text(rcstr!(
+ " cannot be imported from a Server Component module"
+ )),
+ ])
+ .resolved_cell(),
+ description: ResolvedVc::cell(Some(
+ StyledString::Line(vec![StyledString::Text(
+ "It should only be used from a Client Component.".into(),
+ )])
+ .resolved_cell(),
+ )),
+ }
+ .resolved_cell(),
+ ))
+ .resolved_cell(),
+ );
+
+ // styled-jsx imports client-only. So this is effectively the same as above but produces a nicer
+ // import trace.
+ let mapping = ImportMapping::Error(ResolvedVc::upcast(
+ InvalidImportIssue {
+ title: StyledString::Line(vec![
+ StyledString::Code(rcstr!("'styled-jsx'")),
+ StyledString::Text(rcstr!(" cannot be imported from a Server Component module")),
+ ])
+ .resolved_cell(),
+ description: ResolvedVc::cell(Some(
+ StyledString::Line(vec![StyledString::Text(
+ "It only works in a Client Component but none of its parents are marked with \
+ 'use client', so they're Server Components by default."
+ .into(),
+ )])
+ .resolved_cell(),
+ )),
+ }
+ .resolved_cell(),
+ ))
+ .resolved_cell();
+ import_map.insert_exact_alias(rcstr!("styled-jsx"), mapping);
+ import_map.insert_wildcard_alias(rcstr!("styled-jsx/"), mapping);
+}
+
+fn insert_server_only_error_alias(import_map: &mut ImportMap) {
+ import_map.insert_exact_alias(
+ rcstr!("server-only"),
+ ImportMapping::Error(ResolvedVc::upcast(
+ InvalidImportIssue {
+ title: StyledString::Line(vec![
+ StyledString::Code(rcstr!("'server-only'")),
+ StyledString::Text(rcstr!(
+ " cannot be imported from a Client Component module"
+ )),
+ ])
+ .resolved_cell(),
+ description: ResolvedVc::cell(Some(
+ StyledString::Line(vec![StyledString::Text(
+ "It should only be used from a Server Component.".into(),
+ )])
+ .resolved_cell(),
+ )),
+ }
+ .resolved_cell(),
+ ))
+ .resolved_cell(),
+ );
+}
+
+#[turbo_tasks::value(shared)]
+struct InvalidImportIssue {
+ title: ResolvedVc<StyledString>,
+ description: ResolvedVc<OptionStyledString>,
+}
+
+#[turbo_tasks::value_impl]
+impl Issue for InvalidImportIssue {
+ fn severity(&self) -> IssueSeverity {
+ IssueSeverity::Error
+ }
+
+ #[turbo_tasks::function]
+ fn file_path(&self) -> Vc<FileSystemPath> {
+ panic!("InvalidImportIssue::file_path should not be called");
+ }
+
+ #[turbo_tasks::function]
+ fn stage(self: Vc<Self>) -> Vc<IssueStage> {
+ IssueStage::Resolve.cell()
+ }
+
+ #[turbo_tasks::function]
+ fn title(&self) -> Vc<StyledString> {
+ *self.title
+ }
+
+ #[turbo_tasks::function]
+ fn description(&self) -> Vc<OptionStyledString> {
+ *self.description
+ }
+}
+
// To alias e.g. both `import "next/link"` and `import "next/link.js"`
fn insert_exact_alias_or_js(
import_map: &mut ImportMap,
diff --git a/crates/next-core/src/next_server/context.rs b/crates/next-core/src/next_server/context.rs
index 091e64851d1f3..9094190edc122 100644
--- a/crates/next-core/src/next_server/context.rs
+++ b/crates/next-core/src/next_server/context.rs
@@ -53,8 +53,7 @@ use crate::{
next_shared::{
resolve::{
ModuleFeatureReportResolvePlugin, NextExternalResolvePlugin,
- NextNodeSharedRuntimeResolvePlugin, get_invalid_client_only_resolve_plugin,
- get_invalid_styled_jsx_resolve_plugin,
+ NextNodeSharedRuntimeResolvePlugin,
},
transforms::{
EcmascriptTransformStage, emotion::get_emotion_transform_rule, get_ecma_transform_rule,
@@ -153,14 +152,6 @@ pub async fn get_server_resolve_options_context(
ModuleFeatureReportResolvePlugin::new(project_path.clone())
.to_resolved()
.await?;
- let invalid_client_only_resol
... [truncated]