Server-Client boundary violation / Privilege escalation

MEDIUM
vercel/next.js
Commit: 2a9e7560a50c
Affected: < 16.2.2
2026-04-04 07:33 UTC

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]
← Back to Alerts View on GitHub →