fix(javascript): distinguish deferred bootstrap installer writes

This commit is contained in:
morluto committed 2026-10-24 04:23:56 +08:00
1 parent bfa60d596e
commit 9fb63ad066
3 files changed
+120 -2

No files matched your search

+6 -1
View File
@@ -380,7 +380,12 @@ parameter does not change the recovered names. Global property-definition
intrinsics require lexical and visible-write proof. Computed writes through a
standalone loader's exposed module/cache table use the shared value owner; an
unknown key invalidates the original-slot proof rather than implying a fresh
chunk installation. Export names describe recognized declarations, not a proof
chunk installation. A function stored on a standalone loader member does not
write the table merely by being defined: the bootstrap owner excludes its
deferred writes only when that member is unread and the loader namespace stays
confined. Calls, member aliases, dynamic member reads, namespace escapes, or
receiver-based access retain uncertainty about those writes. Export names
describe recognized declarations, not a proof
of final runtime property lifetime or arbitrary helper behavior.
Each recovered bundle module retains the exact factory-source digest. A complete
@@ -246,6 +246,38 @@ describe("Webpack externals with a tampered runtime", () => {
});
describe("Webpack externals with a tampered loader or module object", () => {
it.each([
["invoked", "n.i(runtimeChunks);"],
["read through an alias", "const install=n.i;install(runtimeChunks);"],
["read through a dynamic key", "n[runtimeMember](runtimeChunks);"],
["exposed with its namespace", "publish(n);"],
[
"reachable through a helper receiver",
"n.run=function(){this.i(runtimeChunks)};n.run();",
],
])(
"keeps an unknown chunk installation unresolved when %s",
(_name, invoke) => {
expectUnresolved(
bootstrap(
EXTERNAL,
CONSUMER,
`n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};${invoke}n(100);`,
),
);
},
);
it("does not treat a called assignment value as an unused installer", () => {
expectUnresolved(
bootstrap(
EXTERNAL,
CONSUMER,
"n.m=m;(n.i=e=>{for(var k in e)n.m[k]=e[k]})(runtimeChunks);n(100);",
),
);
});
it.each([
[
"cached exports rewritten through the runtime exposure",
@@ -438,6 +438,12 @@ const referencesAreContained = (
[...tracked.keys()].flatMap((id) => state.bindingsById.get(id)?.name ?? []),
);
const replacedKeys = new Set<string>();
const unreadDefinitions = unreadRuntimeDefinitions(
call,
shape,
loaderIds,
state,
);
let contained = true;
traverseJavaScriptAst(call, {
skip: () => !contained,
@@ -452,12 +458,87 @@ const referencesAreContained = (
const use: Use = { node, parent, ancestors, shape, loaderIds, state };
if (role !== "loader") contained = containedUse(role, use);
else if (exposedTableUse(use) === "escaped") contained = false;
else contained = exposedSlotWrite(use, replacedKeys);
else {
const activation = [...ancestors]
.reverse()
.find((ancestor) => t.isFunction(ancestor));
contained =
(activation !== undefined && unreadDefinitions.has(activation)) ||
exposedSlotWrite(use, replacedKeys);
}
},
});
return contained ? { replacedKeys } : undefined;
};
/**
* A function stored on the confined runtime cannot execute until its member is
* read. Keep this proof local to the bootstrap's existing loader/table def-use
* boundary: aliases, dynamic member reads and escapes invalidate it. Merely
* defining Webpack's chunk installer does not install any chunks.
*/
const unreadRuntimeDefinitions = (
call: t.CallExpression,
shape: LoaderShape,
loaderIds: ReadonlySet<string>,
state: JavaScriptSemanticAnalysisState,
): ReadonlySet<t.Node> => {
const definitions = new Map<t.Node, string>();
const readKeys = new Set<string>();
let confined = true;
traverseJavaScriptAst(call, {
enter: (node, parent, readAncestors) => {
// A helper invoked as `loader.helper()` can obtain the namespace through
// its receiver instead of a tracked loader identifier.
if (t.isThisExpression(node)) confined = false;
if (!t.isIdentifier(node) || parent === null) return;
const ancestors = readAncestors();
if (
!t.isReferenced(node, parent, ancestors.at(-2)) ||
!loaderIds.has(bindingOf(state, node) ?? "")
)
return;
if (
t.isCallExpression(parent) &&
(parent.callee === node ||
(parent === shape.dispatch && parent.arguments.at(-1) === node))
)
return;
if (!t.isMemberExpression(parent) || parent.object !== node) {
confined = false;
return;
}
const key = semanticStaticPropertyKey(parent.property, parent.computed);
if (key === null) {
confined = false;
return;
}
const assignment = ancestors.at(-2);
if (
t.isAssignmentExpression(assignment, { operator: "=" }) &&
assignment.left === parent
) {
if (
key !== "m" &&
key !== "c" &&
t.isFunction(assignment.right) &&
t.isExpressionStatement(ancestors.at(-3))
)
definitions.set(assignment.right, key);
return;
}
readKeys.add(key);
},
});
return new Set(
confined
? [...definitions]
.filter(([, key]) => !readKeys.has(key))
.map(([node]) => node)
: [],
);
};
interface Use {
readonly node: t.Identifier;
readonly parent: t.Node;