Skip to content

Commit 9deee55

Browse files
authored
fix: opt-in mitigation for React RCE/DoS CVEs (#34676)
This adds opt-in, load-time source patches that neutralize two known React Server Components vulnerabilities shipped in affected react-server-dom-* builds: CVE-2025-55182 (RCE), where deserialized model keys are not filtered and a crafted payload can reach constructor / prototype / _response, and CVE-2025-55184 (DoS), where a cyclic thenable makes chunk fulfillment loop forever. The fix rewrites the affected snippets as the source is loaded, which lets us protect applications that depend on a vulnerable build without waiting on an upstream package release. The mitigation is opt-in via the DENO_PATCH_REACT_CVE environment variable, read once and cached at startup so it cannot be toggled later from user code. When disabled (the default) the hot path is a single cached bool check, so there is no cost for the common case. When enabled, a single \"resolved_model\" substring scan short-circuits the overwhelmingly common module before any pattern matching runs, and only JavaScript module source is considered. The patch is wired into all three module load paths: ESM loading in the CLI, CommonJS-to-ESM translation in deno_resolver, and require() file reads in ext/node.
1 parent 3ac850f commit 9deee55

17 files changed

Lines changed: 352 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/args/flags.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,6 +1654,11 @@ static ENV_VARS: &[EnvVar] = &[
16541654
description: "Set to disable checking if a newer Deno version is available",
16551655
example: None,
16561656
},
1657+
EnvVar {
1658+
name: "DENO_PATCH_REACT_CVE",
1659+
description: "Enable load-time source patches mitigating known React Server\nComponents CVEs (CVE-2025-55182, CVE-2025-55184).",
1660+
example: None,
1661+
},
16571662
EnvVar {
16581663
name: "DENO_SERVE_ADDRESS",
16591664
description: "Override address for Deno.serve",

cli/module_loader.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,38 @@ pub enum CliModuleLoaderError {
602602
ResolveReferrer(#[from] ResolveReferrerError),
603603
}
604604

605+
/// Applies `deno_resolver::patch_react_cves` to JavaScript module source,
606+
/// handling both string and (valid UTF-8) byte representations without
607+
/// allocating when no patch is needed.
608+
fn patch_react_cves_source(
609+
specifier: &ModuleSpecifier,
610+
code: ModuleSourceCode,
611+
) -> ModuleSourceCode {
612+
// Borrow the source as `&str` and compute the patched output (if any) in an
613+
// inner scope so the borrow of `code` ends before we move it below.
614+
let patched: Option<String> = {
615+
let src = match &code {
616+
ModuleSourceCode::String(s) => s.as_str(),
617+
ModuleSourceCode::Bytes(b) => match std::str::from_utf8(b.as_bytes()) {
618+
Ok(s) => s,
619+
// Non UTF-8 source can't contain the ASCII patterns we look for.
620+
Err(_) => return code,
621+
},
622+
};
623+
match deno_resolver::patch_react_cves(
624+
specifier.as_str(),
625+
Cow::Borrowed(src),
626+
) {
627+
Cow::Borrowed(_) => None,
628+
Cow::Owned(s) => Some(s),
629+
}
630+
};
631+
match patched {
632+
Some(s) => ModuleSourceCode::String(s.into()),
633+
None => code,
634+
}
635+
}
636+
605637
impl<TGraphContainer: ModuleGraphContainer>
606638
CliModuleLoaderInner<TGraphContainer>
607639
{
@@ -638,6 +670,17 @@ impl<TGraphContainer: ModuleGraphContainer>
638670
code_without_source_map(code_source.code)
639671
};
640672

673+
// Apply load-time security mitigations for known React Server Components
674+
// CVEs to JavaScript source. Opt in via `DENO_PATCH_REACT_CVE`. See
675+
// `deno_resolver::patch_react_cves`.
676+
let code = if code_source.module_type == ModuleType::JavaScript
677+
&& deno_resolver::is_react_cve_patch_enabled(&self.shared.sys)
678+
{
679+
patch_react_cves_source(specifier, code)
680+
} else {
681+
code
682+
};
683+
641684
let code_cache = if code_source.module_type == ModuleType::JavaScript {
642685
self.shared.code_cache.as_ref().map(|cache| {
643686
let code_hash = FastInsecureHasher::new_deno_versioned()

ext/node/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ deno_package_json.workspace = true
3535
deno_path_util.workspace = true
3636
deno_permissions.workspace = true
3737
deno_process.workspace = true
38+
deno_resolver.workspace = true
3839
deno_tls.workspace = true
3940
deno_whoami.workspace = true
4041
filetime.workspace = true

ext/node/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ deno_core::extension!(deno_node,
358358
ops::require::op_require_stat<TSys>,
359359
ops::require::op_require_path_resolve,
360360
ops::require::op_require_path_basename,
361-
ops::require::op_require_read_file,
361+
ops::require::op_require_read_file<TSys>,
362362
ops::require::op_require_as_file_path,
363363
ops::require::op_require_resolve_exports<TInNpmPackageChecker, TNpmPackageFolderResolver, TSys>,
364364
ops::require::op_require_read_package_scope<TSys>,

ext/node/ops/require.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -562,18 +562,31 @@ pub fn op_require_try_self<
562562
}
563563

564564
#[op2(stack_trace)]
565-
pub fn op_require_read_file(
565+
pub fn op_require_read_file<TSys: ExtNodeSys + 'static>(
566566
state: &mut OpState,
567-
#[string] file_path: &str,
567+
#[string] file_path_str: &str,
568568
) -> Result<FastString, RequireError> {
569-
let file_path = Cow::Borrowed(Path::new(file_path));
569+
let file_path = Cow::Borrowed(Path::new(file_path_str));
570570
// todo(dsherret): there's multiple borrows to NodeRequireLoaderRc here
571571
let file_path = ensure_read_permission(state, file_path)
572572
.map_err(RequireErrorKind::Permission)?;
573-
let loader = state.borrow::<NodeRequireLoaderRc>();
574-
loader
575-
.load_text_file_lossy(&file_path)
576-
.map_err(|e| RequireErrorKind::ReadModule(e).into_box())
573+
let code = {
574+
let loader = state.borrow::<NodeRequireLoaderRc>();
575+
loader
576+
.load_text_file_lossy(&file_path)
577+
.map_err(|e| RequireErrorKind::ReadModule(e).into_box())?
578+
};
579+
// Apply load-time security mitigations for known React Server Components
580+
// CVEs to required (CommonJS) source. Opt in via `DENO_PATCH_REACT_CVE`.
581+
let sys = state.borrow::<TSys>();
582+
if deno_resolver::is_react_cve_patch_enabled(sys) {
583+
match deno_resolver::patch_react_cves(file_path_str, code.as_str().into()) {
584+
Cow::Borrowed(_) => Ok(code),
585+
Cow::Owned(s) => Ok(s.into()),
586+
}
587+
} else {
588+
Ok(code)
589+
}
577590
}
578591

579592
#[op2]

libs/resolver/lib.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,155 @@ impl<
695695
)
696696
}
697697
}
698+
699+
/// Whether the React CVE source patches are enabled. Opt in by setting the
700+
/// `DENO_PATCH_REACT_CVE` environment variable to a non-empty value other than
701+
/// `0`.
702+
///
703+
/// The variable is read exactly once (via `sys`) and cached. Module loading
704+
/// happens before any user code runs, so the value is effectively snapshotted
705+
/// at startup and cannot be toggled later via `Deno.env.set`.
706+
pub fn is_react_cve_patch_enabled(sys: &impl sys_traits::EnvVar) -> bool {
707+
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
708+
*ENABLED.get_or_init(|| {
709+
sys
710+
.env_var("DENO_PATCH_REACT_CVE")
711+
.map(|v| !v.is_empty() && v != "0")
712+
.unwrap_or(false)
713+
})
714+
}
715+
716+
/// Load time source patches that neutralize two known React Server Components
717+
/// vulnerabilities shipped in affected `react-server-dom-*` builds:
718+
///
719+
/// * CVE-2025-55182 (RCE): deserialized model keys are not filtered, letting a
720+
/// crafted payload reach `constructor` / `prototype` / `_response`.
721+
/// * CVE-2025-55184 (DoS): a cyclic thenable makes chunk fulfillment loop
722+
/// forever.
723+
///
724+
/// Callers must gate this behind [`is_react_cve_patch_enabled`] (the patches
725+
/// are opt-in via the `DENO_PATCH_REACT_CVE` environment variable) and only
726+
/// apply it to JavaScript source.
727+
pub fn patch_react_cves<'a>(
728+
filename: &str,
729+
code: Cow<'a, str>,
730+
) -> Cow<'a, str> {
731+
let mut modified = Cow::Borrowed(&*code);
732+
let mut offset = 0;
733+
734+
for (index, _) in code.match_indices("split(") {
735+
let index = index + offset;
736+
737+
let len = "split(".len();
738+
if !modified[index + len..].starts_with("':')")
739+
&& !modified[index + len..].starts_with("\":\")")
740+
{
741+
continue;
742+
}
743+
744+
let Some((end, _)) = modified[index..].char_indices().nth(100) else {
745+
break;
746+
};
747+
748+
if !modified[index..index + end].contains("resolved_model") {
749+
continue;
750+
}
751+
752+
let insert =
753+
".filter(x => !(['constructor', 'prototype', '_response'].includes(x)))";
754+
let mut s = String::with_capacity(modified.len() + insert.len());
755+
s.push_str(&modified[0..index + len + 4]);
756+
s.push_str(insert);
757+
offset += insert.len();
758+
s.push_str(&modified[(index + len + 4)..]);
759+
modified = Cow::Owned(s);
760+
}
761+
762+
if offset > 0 {
763+
log::debug!("Patched React RCE (CVE-2025-55182) in {filename}");
764+
}
765+
766+
// Collapse the stage 1 (RCE) result back into a `Cow<'a>` before stage 2
767+
// borrows it: the original input when nothing was patched, otherwise the
768+
// owned patched string. Stage 2 must fall back to this (not `code`) so a
769+
// stage 1 patch is preserved even when stage 2 makes no change.
770+
let stage1: Cow<'a, str> = match modified {
771+
Cow::Borrowed(_) => code,
772+
Cow::Owned(s) => Cow::Owned(s),
773+
};
774+
775+
let code2 = &*stage1;
776+
let mut modified = Cow::Borrowed(&*stage1);
777+
let mut offset = 0;
778+
779+
const PROTOTYPE_THEN: &str = ".prototype.then";
780+
'outer: for (index, _) in code2.match_indices(PROTOTYPE_THEN) {
781+
let index = index + offset;
782+
783+
let after_equals;
784+
if modified[index + PROTOTYPE_THEN.len()..].starts_with(" =") {
785+
after_equals = index + PROTOTYPE_THEN.len() + 2;
786+
} else if modified[index + PROTOTYPE_THEN.len()..].starts_with("=") {
787+
after_equals = index + PROTOTYPE_THEN.len() + 1;
788+
} else {
789+
continue;
790+
};
791+
792+
let Some((end, _)) = modified[index..].char_indices().nth(300) else {
793+
break;
794+
};
795+
796+
if !modified[index..index + end].contains("resolved_model") {
797+
continue;
798+
}
799+
800+
if !modified[index..index + end].contains("fulfilled") {
801+
continue;
802+
}
803+
804+
// now match { until we end up at the next }
805+
let mut brace_count = 0;
806+
let mut insert_index = None;
807+
for (i, c) in modified[index..].char_indices() {
808+
if c == '{' {
809+
brace_count += 1;
810+
} else if c == '}' {
811+
if brace_count == 0 {
812+
continue 'outer;
813+
}
814+
brace_count -= 1;
815+
if brace_count == 0 {
816+
insert_index = Some(index + i + 1);
817+
break;
818+
}
819+
}
820+
}
821+
let Some(insert_index) = insert_index else {
822+
continue;
823+
};
824+
825+
const INSERT_BEFORE: &str = "(()=>{const __old=";
826+
const INSERT_AFTER: &str = ";const __new=function(res, rej){return __old.call(this,(v)=>{let w=v;let l=0;while(w&&typeof w==='object'&&w.then===__new){l++;if(w===this||l>1000){if(typeof rej==='function')rej(new Error('Cannot have cyclic thenables.'));return}if(w.status==='fulfilled')w=w.value;else break;}res(v)},rej)};return __new})()";
827+
828+
// after the = insert the before, and after the } insert the after
829+
let mut s = String::with_capacity(
830+
modified.len() + INSERT_BEFORE.len() + INSERT_AFTER.len(),
831+
);
832+
s.push_str(&modified[0..after_equals]);
833+
s.push_str(INSERT_BEFORE);
834+
s.push_str(&modified[after_equals..insert_index]);
835+
s.push_str(INSERT_AFTER);
836+
s.push_str(&modified[insert_index..]);
837+
offset += INSERT_BEFORE.len() + INSERT_AFTER.len();
838+
modified = Cow::Owned(s);
839+
}
840+
841+
if offset > 0 {
842+
log::debug!("Patched React DoS (CVE-2025-55184) in {filename}");
843+
}
844+
845+
match modified {
846+
Cow::Borrowed(_) => stage1,
847+
Cow::Owned(s) => Cow::Owned(s),
848+
}
849+
}

libs/resolver/loader/module_loader.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,13 @@ impl<TSys: ModuleLoaderSys> PreparedModuleLoader<TSys> {
550550
.node_code_translator
551551
.translate_cjs_to_esm(specifier, Some(Cow::Borrowed(js_source.as_ref())))
552552
.await?;
553+
// Apply load-time security mitigations for known React Server Components
554+
// CVEs to the translated source. Opt in via `DENO_PATCH_REACT_CVE`.
555+
let text = if crate::is_react_cve_patch_enabled(&self.sys) {
556+
crate::patch_react_cves(specifier.as_str(), text)
557+
} else {
558+
text
559+
};
553560
// at this point, we no longer need the parsed source in memory, so free it
554561
self.parsed_source_cache.free(specifier);
555562
Ok(match text {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"tests": {
3+
"disabled": {
4+
"args": "run main.js",
5+
"output": "disabled.out"
6+
},
7+
"enabled": {
8+
"args": "run main.js",
9+
"envs": {
10+
"DENO_PATCH_REACT_CVE": "1"
11+
},
12+
"output": "enabled.out"
13+
},
14+
"rce_only_disabled": {
15+
"args": "run rce_only_main.js",
16+
"output": "keys: [\"a\",\"constructor\",\"b\"]\n"
17+
},
18+
"rce_only_enabled": {
19+
"args": "run rce_only_main.js",
20+
"envs": {
21+
"DENO_PATCH_REACT_CVE": "1"
22+
},
23+
"output": "keys: [\"a\",\"b\"]\n"
24+
},
25+
"dos_only_disabled": {
26+
"args": "run dos_only_main.js",
27+
"output": "then: resolved-cycle\n"
28+
},
29+
"dos_only_enabled": {
30+
"args": "run dos_only_main.js",
31+
"envs": {
32+
"DENO_PATCH_REACT_CVE": "1"
33+
},
34+
"output": "then: rejected Cannot have cyclic thenables.\n"
35+
}
36+
}
37+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
keys: ["a","constructor","b"]
2+
then: resolved-cycle

0 commit comments

Comments
 (0)