Skip to content

Commit c642ec2

Browse files
committed
feat: preserve native direct eval scope by rewriting eval source at call sites instead of proxying global eval
1 parent 2097168 commit c642ec2

6 files changed

Lines changed: 125 additions & 53 deletions

File tree

rewriter-rs/src/js/swc_rewriter.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ const GLOBAL_NAMES: &[&str] = &[
3232
"opener",
3333
"frames",
3434
"WebSocket",
35-
"eval",
3635
"Function",
3736
"AsyncFunction",
3837
"GeneratorFunction",
@@ -202,6 +201,18 @@ impl VisitMut for SwcRewriter<'_> {
202201
self.rewrite_dynamic_import(call);
203202
return;
204203
}
204+
if self.is_direct_eval_callee(&call.callee) {
205+
for arg in &mut call.args {
206+
arg.expr.visit_mut_with(self);
207+
}
208+
if let Some(first) = call.args.first_mut() {
209+
if first.spread.is_none() {
210+
let source = *first.expr.clone();
211+
*first.expr = call_helper("__zp_eval_source", vec![source]);
212+
}
213+
}
214+
return;
215+
}
205216
if let Some((base, prop)) = self.call_target_parts(&call.callee, false) {
206217
for arg in &mut call.args {
207218
arg.expr.visit_mut_with(self);
@@ -529,6 +540,7 @@ impl SwcRewriter<'_> {
529540
match &member.prop {
530541
MemberProp::Ident(id) => {
531542
MEMBER_HELPER_PROPS.contains(&id.sym.as_ref())
543+
|| id.sym == *"eval" && self.is_window_like_expr(&member.obj)
532544
|| matches!(id.sym.as_ref(), "href" | "hash")
533545
&& self.is_virtual_location_expr(&member.obj)
534546
}
@@ -643,6 +655,14 @@ impl SwcRewriter<'_> {
643655
}
644656
}
645657

658+
fn is_direct_eval_callee(&self, callee: &Callee) -> bool {
659+
matches!(
660+
callee,
661+
Callee::Expr(expr)
662+
if matches!(&**expr, Expr::Ident(id) if id.sym == *"eval" && self.is_unresolved(id.ctxt))
663+
)
664+
}
665+
646666
fn construct_target(&mut self, callee: &Expr) -> Option<Expr> {
647667
match callee {
648668
Expr::Ident(id) if self.is_global_ident(id) => Some(self.global_get_expr(id)),

test/e2e/proxy.test.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,26 @@ function createTargetServer(requests) {
7676
if (url.pathname === '/') {
7777
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
7878
res.end(`<!doctype html><html><head><title>E2E Home</title><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'none'"><meta http-equiv="Content-Security-Policy-Report-Only" content="default-src 'none'; connect-src 'none'"><link rel="stylesheet" href="/site.css"><link id="icon-link" rel="icon" href="/site-icon.png"></head><body>
79-
<main id="style-probe" class="root-stylesheet-probe"><h1>E2E Home</h1><img id="image-probe" src="/image-probe.png" alt=""><a id="next" href="/next">Next page</a></main>
79+
<main id="style-probe" class="root-stylesheet-probe"><h1>E2E Home</h1><img id="image-probe" src="/image-probe.png" alt=""><span class="masked-item"></span><span class="masked-item"></span><a id="next" href="/next">Next page</a></main>
8080
<script>
8181
window.__ua = navigator.userAgent;
8282
window.__platform = navigator.platform;
8383
window.__phase2Location = { href: location.href, windowHref: window.location.href };
8484
window.__storageInitial = { local: localStorage.getItem('zp-persist'), session: sessionStorage.getItem('zp-session') };
8585
window.__phase2DynamicFunction = Function('return location.href')();
8686
window.__phase2EvalLocation = eval('location.href');
87+
window.__phase2WindowEvalLocation = window.eval('location.href');
88+
const indirectEvalAlias = window.eval;
89+
window.__phase2IndirectEvalLocation = indirectEvalAlias('location.href');
90+
window.__maskedSelectorEval = (() => {
91+
const localCollector = (root, className) => root.getElementsByClassName(className);
92+
const buildSelector = () => {
93+
const generated = "(function(root) { return localCollector(root, 'masked-item').length; })";
94+
eval('var compiledSelector = ' + generated + ';');
95+
return compiledSelector;
96+
};
97+
return buildSelector()(document);
98+
})();
8799
window.__messageEvents = [];
88100
window.addEventListener('message', ev => {
89101
if (ev.data && ev.data.type) window.__messageEvents.push({ type: ev.data.type, origin: ev.origin, href: ev.data.href || '' });
@@ -1645,6 +1657,9 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
16451657
phase2Location: window.__phase2Location,
16461658
phase2DynamicFunction: window.__phase2DynamicFunction,
16471659
phase2EvalLocation: window.__phase2EvalLocation,
1660+
phase2WindowEvalLocation: window.__phase2WindowEvalLocation,
1661+
phase2IndirectEvalLocation: window.__phase2IndirectEvalLocation,
1662+
maskedSelectorEval: window.__maskedSelectorEval,
16481663
innerHTMLScriptFixture: window.__innerHTMLScriptFixture,
16491664
styleProbe: (() => {
16501665
const el = document.getElementById('style-probe');
@@ -1783,6 +1798,9 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
17831798
});
17841799
assert.equal(home.phase2DynamicFunction, `http://${targetHost}:${targetPort}/`);
17851800
assert.equal(home.phase2EvalLocation, `http://${targetHost}:${targetPort}/`);
1801+
assert.equal(home.phase2WindowEvalLocation, `http://${targetHost}:${targetPort}/`);
1802+
assert.equal(home.phase2IndirectEvalLocation, `http://${targetHost}:${targetPort}/`);
1803+
assert.equal(home.maskedSelectorEval, 2);
17861804
assert.equal(home.innerHTMLScriptFixture, `http://${targetHost}:${targetPort}/`);
17871805
assert.deepEqual(home.styleProbe, {
17881806
borderTopWidth: '7px',

test/js/rewriter.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,34 @@ test('Rust rewriter virtualizes dangerous globals without rewriting local bindin
877877
assert.match(out.code, /__zp_get\(globalThis,"Function"\)/);
878878
});
879879

880+
test('Rust rewriter preserves bare eval as lexical direct eval', async () => {
881+
const rewriter = await loadRewriter();
882+
const out = rewriter.rewriteScript(
883+
`
884+
(function() {
885+
var localCollector = function(root) { return root.items.length; };
886+
var source = "(function(root) { return localCollector(root); })";
887+
eval("var compiledSelector = " + source + ";");
888+
window.__maskedSelectorEval = compiledSelector({ items: [1, 2, 3] });
889+
})();
890+
window.__maskedEvalOrigin = eval('location.origin');
891+
window.__maskedWindowEvalOrigin = window.eval('location.origin');
892+
var maskedIndirectEval = window.eval;
893+
window.__maskedIndirectEvalOrigin = maskedIndirectEval('location.origin');
894+
`,
895+
{ kind: 'classic' },
896+
);
897+
assert.equal(out.ok, true, JSON.stringify(out.diagnostics));
898+
assert.match(out.code, /\beval\(__zp_eval_source\("var compiledSelector = "\+source\+";"\)\)/);
899+
assert.match(out.code, /\beval\(__zp_eval_source\("location.origin"\)\)/);
900+
assert.match(
901+
out.code,
902+
/__zp_call\(__zp_get\(globalThis,"window"\),"eval",\["location.origin"\]\)/,
903+
);
904+
assert.match(out.code, /maskedIndirectEval=__zp_get\(__zp_get\(globalThis,"window"\),"eval"\)/);
905+
assert.doesNotMatch(out.code, /__zp_get\(globalThis,"eval"\)/);
906+
});
907+
880908
test('Rust rewriter supports modules and fails closed on parse errors', async () => {
881909
const rewriter = await loadRewriter();
882910
const mod = rewriter.rewriteScript(

test/js/static-policy.test.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,46 @@ test('runtime dynamic constructor descriptors stay assignable for app bundles',
109109
assert.equal(rt.includes("define(w, 'Function', root.Function)"), false);
110110
});
111111

112-
test('runtime dynamic eval uses one native-scoped path without rewritten fallback', () => {
112+
test('runtime preserves native direct eval for lexical generated functions', () => {
113113
const rt = readRuntimeSource();
114114
assert.ok(
115115
rt.includes('eval: w.eval'),
116-
'native eval capture is required for strict app-bundle compatibility',
116+
'native eval capture is required for string timer compatibility',
117+
);
118+
assert.equal(
119+
rt.includes("defineReplacingNative(root, 'eval'"),
120+
false,
121+
'bare eval must keep native direct-eval lexical scope',
122+
);
123+
assert.equal(
124+
rt.includes("if (name === 'eval')"),
125+
false,
126+
'global helper reads must not redirect bare eval through a facade',
117127
);
118128
assert.ok(
119-
rt.includes('return runScopedNativeEval(String(source));'),
120-
'dynamic eval must use the single scoped eval path',
129+
rt.includes("return rewritePageSource(source, 'classic');"),
130+
'direct eval source must go through the JavaScript rewriter',
121131
);
122132
assert.ok(
123-
rt.includes('(0, Native.eval)(`with(__ZP_EVAL_SCOPE){${expr}\\n}`)'),
124-
'scoped eval must preserve native eval semantics',
133+
rt.includes('return current === Native.eval ? indirectEval : current;'),
134+
'native window.eval reads must receive the indirect rewritten eval wrapper',
135+
);
136+
assert.equal(
137+
rt.includes('with(__zp_eval_scope())'),
138+
false,
139+
'direct eval must not use a with-scope wrapper',
140+
);
141+
const dynamic = fs.readFileSync('web/runtime/dynamic-code/facade.mjs', 'utf8');
142+
assert.ok(
143+
dynamic.includes(
144+
"return (0, Native.eval)(rewriteScriptSource(String(text || ''), 'classic'));",
145+
),
146+
'string timers still execute rewritten source through native indirect eval',
147+
);
148+
assert.equal(
149+
dynamic.includes('__ZP_EVAL_SCOPE'),
150+
false,
151+
'string timers must not use with-scope eval state',
125152
);
126153
assert.equal(
127154
rt.includes('return compileEvalSource(text).call(root, scope);'),

web/runtime-prelude.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
971971
dynamicCompileAllowed,
972972
normalizedError,
973973
getVirtualURL: () => virtualURL,
974-
getScope: () => scope,
974+
rewriteScriptSource: rewritePageSource,
975975
define,
976976
defineReplacingNative,
977977
maskNativeFunction,
@@ -1121,6 +1121,10 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
11211121
if (prop === 'location') return base === scope || base === root ? virtualLocation : base.location;
11221122
if (prop === 'origin') return base === scope || base === root ? virtualURL.origin : base.location && base.location.origin;
11231123
if (prop === 'postMessage') return postMessageWrapperFor(base === scope ? root : base);
1124+
if (prop === 'eval' && (base === scope || base === root)) {
1125+
const current = Reflect.get(root, 'eval');
1126+
return current === Native.eval ? indirectEval : current;
1127+
}
11241128
const dynamic = dynamicGlobal(prop);
11251129
if (dynamic) return dynamic;
11261130
}
@@ -1235,6 +1239,19 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
12351239
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw normalizedError('NotSupportedError');
12361240
return scriptProxyPath(u.href, 'module');
12371241
}
1242+
function evalSource(source) {
1243+
if (typeof source !== 'string') return source;
1244+
return rewritePageSource(source, 'classic');
1245+
}
1246+
function indirectEval(source) {
1247+
if (arguments.length === 0) return undefined;
1248+
if (typeof source !== 'string') return source;
1249+
if (typeof Native.eval !== 'function') throw normalizedError('NotSupportedError');
1250+
return (0, Native.eval)(evalSource(source));
1251+
}
1252+
try { Object.defineProperty(indirectEval, 'name', { value: 'eval', configurable: true }); } catch {}
1253+
try { Object.defineProperty(indirectEval, 'length', { value: 1, configurable: true }); } catch {}
1254+
maskNativeFunction(indirectEval, 'eval');
12381255
define(root, '__zp_get', get);
12391256
define(root, '__zp_optionalGet', optionalGet);
12401257
define(root, '__zp_set', set);
@@ -1249,6 +1266,7 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
12491266
define(root, '__zp_getOwnPropertyDescriptor', getOwnPropertyDescriptor);
12501267
define(root, '__zp_ownKeys', ownKeys);
12511268
define(root, '__zp_module_url', moduleURL);
1269+
define(root, '__zp_eval_source', evalSource);
12521270
define(root, '__zp_nav_assign', v => setVirtualLocation(v));
12531271
define(root, '__zp_nav_replace', v => setVirtualLocation(v, true));
12541272
define(root, '__zp_runClassic', fn => fn.call(root, scope));

web/runtime/dynamic-code/facade.mjs

Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
dynamicSource,
3-
isEvalExpressionCandidate,
43
simpleDynamicValue,
54
stringArgs,
65
} from './source.mjs';
@@ -11,7 +10,7 @@ export function createDynamicCodeFacade({
1110
dynamicCompileAllowed,
1211
normalizedError,
1312
getVirtualURL,
14-
getScope,
13+
rewriteScriptSource,
1514
define,
1615
defineReplacingNative,
1716
maskNativeFunction,
@@ -54,7 +53,7 @@ export function createDynamicCodeFacade({
5453

5554
function compileTimerString(source) {
5655
const text = String(source || '');
57-
return function anonymous() { return runScopedNativeEval(text); };
56+
return function anonymous() { return runRewrittenNativeEval(text); };
5857
}
5958

6059
function compileDynamic(ctor, args, kind) {
@@ -70,43 +69,10 @@ export function createDynamicCodeFacade({
7069
return fn;
7170
}
7271

73-
function dynamicEval(source) {
74-
if (arguments.length === 0) return undefined;
75-
if (!dynamicCompileAllowed) throw normalizedError('SecurityError');
76-
return runScopedNativeEval(String(source));
77-
}
78-
79-
function runScopedNativeEval(text) {
72+
function runRewrittenNativeEval(text) {
8073
if (typeof Native.eval !== 'function') throw normalizedError('NotSupportedError');
81-
const previous = root.__ZP_EVAL_SCOPE;
82-
const hadPrevious = Object.hasOwn(root, '__ZP_EVAL_SCOPE');
83-
Object.defineProperty(root, '__ZP_EVAL_SCOPE', {
84-
value: getScope(),
85-
enumerable: false,
86-
configurable: true,
87-
writable: true,
88-
});
89-
try {
90-
const expr = isEvalExpressionCandidate(text) ? `(${text})` : text;
91-
return (0, Native.eval)(`with(__ZP_EVAL_SCOPE){${expr}\n}`);
92-
} finally {
93-
restoreEvalScope(previous, hadPrevious);
94-
}
95-
}
96-
97-
function restoreEvalScope(previous, hadPrevious) {
98-
try {
99-
if (hadPrevious) {
100-
Object.defineProperty(root, '__ZP_EVAL_SCOPE', {
101-
value: previous,
102-
enumerable: false,
103-
configurable: true,
104-
writable: true,
105-
});
106-
} else {
107-
delete root.__ZP_EVAL_SCOPE;
108-
}
109-
} catch {}
74+
if (typeof rewriteScriptSource !== 'function') throw normalizedError('NotSupportedError');
75+
return (0, Native.eval)(rewriteScriptSource(String(text || ''), 'classic'));
11076
}
11177

11278
function setDynamicConstructorIdentity(fn, name, proto) {
@@ -130,7 +96,6 @@ export function createDynamicCodeFacade({
13096
}
13197

13298
function dynamicGlobal(name) {
133-
if (name === 'eval') return dynamicEval;
13499
if (name === 'Function') return dynamicFunction;
135100
if (name === 'AsyncFunction') return dynamicAsyncFunction;
136101
if (name === 'GeneratorFunction') return dynamicGeneratorFunction;
@@ -182,10 +147,6 @@ export function createDynamicCodeFacade({
182147
'AsyncGeneratorFunction',
183148
NativeAsyncGeneratorFunction && NativeAsyncGeneratorFunction.prototype,
184149
);
185-
try { Object.defineProperty(dynamicEval, 'name', { value: 'eval', configurable: true }); } catch {}
186-
try { Object.defineProperty(dynamicEval, 'length', { value: 1, configurable: true }); } catch {}
187-
maskNativeFunction(dynamicEval, 'eval');
188-
defineReplacingNative(root, 'eval', dynamicEval);
189150
defineReplacingNative(root, 'Function', dynamicFunction);
190151
installDynamicConstructorBackrefs();
191152
if (Native.setTimeout) {

0 commit comments

Comments
 (0)