Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 42 additions & 57 deletions packages/core/src/asset/ResourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class ResourceManager {
/** Asset path pool, key is the `instanceID` of resource, value is asset path. */
private _assetPool: Record<number, string> = Object.create(null);
/** Asset url pool, key is the asset path and the value is the asset. */
private _assetUrlPool: Record<string, Object> = Object.create(null);
private _assetUrlPool: Record<string, object> = Object.create(null);

/** Referable resource pool, key is the `instanceID` of resource. */
private _referResourcePool: Record<number, ReferResource> = Object.create(null);
Expand Down Expand Up @@ -88,7 +88,7 @@ export class ResourceManager {
*/
load<T extends EngineObject>(path: string): AssetPromise<T>;

load<T>(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise<T | Object[]> {
load<T>(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise<T | T[]> {
// single item
if (!Array.isArray(assetInfo)) {
return this._loadSingleItem(assetInfo);
Expand Down Expand Up @@ -221,21 +221,6 @@ export class ResourceManager {
}
}

/**
* @internal
*/
_onSubAssetFail(assetBaseURL: string, assetSubPath: string, value: Error): void {
const subPromiseCallback = this._subAssetPromiseCallbacks[assetBaseURL]?.[assetSubPath];
if (subPromiseCallback) {
subPromiseCallback.reject(value);
} else {
// Pending
(this._subAssetPromiseCallbacks[assetBaseURL] ||= {})[assetSubPath] = {
rejectedValue: value
};
}
}

/**
* @internal
*/
Expand Down Expand Up @@ -339,7 +324,7 @@ export class ResourceManager {
this._loadingPromises = null;
}

private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: EditorResourceItem): void {
private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: VirtualResource): void {
assetInfo.type = virtualResourceEntry?.type ?? assetInfo.type ?? ResourceManager._getTypeByUrl(assetInfo.url);
if (assetInfo.type === undefined) {
throw `asset type should be specified: ${assetInfo.url}`;
Expand Down Expand Up @@ -416,11 +401,8 @@ export class ResourceManager {
const mainPromise =
loadingPromises[remoteAssetBaseURL] ||
this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName);
mainPromise.catch((e) => {
this._onSubAssetFail(remoteAssetBaseURL, queryPath, e);
});

return this._createSubAssetPromiseCallback<T>(remoteAssetBaseURL, remoteAssetURL, queryPath);
return this._createSubAssetPromiseCallback<T>(remoteAssetBaseURL, remoteAssetURL, queryPath, mainPromise);
}

return this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName);
Expand Down Expand Up @@ -461,30 +443,35 @@ export class ResourceManager {
private _createSubAssetPromiseCallback<T>(
remoteAssetBaseURL: string,
remoteAssetURL: string,
assetSubPath: string
assetSubPath: string,
mainPromise: AssetPromise<unknown>
): AssetPromise<T> {
const loadingPromises = this._loadingPromises;
const subPromiseCallback = this._subAssetPromiseCallbacks[remoteAssetBaseURL]?.[assetSubPath];
const resolvedValue = subPromiseCallback?.resolvedValue;
const rejectedValue = subPromiseCallback?.rejectedValue;

// Already resolved or rejected
if (resolvedValue || rejectedValue) {
return new AssetPromise<T>((resolve, reject) => {
if (resolvedValue) {
resolve(resolvedValue);
} else if (rejectedValue) {
reject(rejectedValue);
}
});

// Already resolved
if (resolvedValue) {
return AssetPromise.resolve(resolvedValue);
}

// Pending
const promise = new AssetPromise<T>((resolve, reject) => {
const promise = new AssetPromise<T>((resolve, reject, setTaskCompleteProgress, setTaskDetailProgress) => {
(this._subAssetPromiseCallbacks[remoteAssetBaseURL] ||= {})[assetSubPath] = {
resolve,
reject
};

// A loader may finish the main asset before its eager sub-asset notification reaches this callback.
// Always resolve from the completed main asset as the authoritative fallback so callback cleanup cannot
// strand a sub-asset request.
mainPromise.onProgress(setTaskCompleteProgress, setTaskDetailProgress).then((resource) => {
try {
resolve(this._getResolveResource(resource, this._parseQueryPath(assetSubPath)) as T);
} catch (error) {
reject(error);
}
}, reject);
});

loadingPromises[remoteAssetURL] = promise;
Expand Down Expand Up @@ -514,7 +501,10 @@ export class ResourceManager {
if (paths) {
for (let i = 0, n = paths.length; i < n; i++) {
const path = paths[i];
subResource = subResource[path];
subResource = subResource?.[path];
if (subResource === undefined) {
throw new Error(`Sub-asset path does not exist: ${paths.join(".")}`);
}
}
}
return subResource;
Expand Down Expand Up @@ -560,12 +550,23 @@ export class ResourceManager {
delete this._subAssetPromiseCallbacks[assetBaseURL];
}

//-----------------Editor temp solution-----------------
// Virtual resource mapping

/** @internal */
_objectPool: { [key: string]: any } = Object.create(null);
/** @internal */
_virtualPathResourceMap: Record<VirtualPath, EditorResourceItem> = Object.create(null);
_virtualPathResourceMap: Record<VirtualPath, VirtualResource> = Object.create(null);

/**
* Register virtual asset paths and their load descriptors.
* @remarks References inside runtime scenes and Prefabs can keep stable virtual paths while the backing URLs
* are generated dynamically, such as object URLs created from a resource package.
*/
registerVirtualResources(resources: readonly VirtualResource[]): void {
resources.forEach((resource) => {
this._virtualPathResourceMap[resource.virtualPath] = resource;
});
}

/**
* @internal
Expand Down Expand Up @@ -594,21 +595,6 @@ export class ResourceManager {
const promise = this.load<T>({ url: loadUrl });
return isClone ? promise.then((item) => <T>(<IClone>(<unknown>item)).clone()) : promise;
}

/**
* @internal
* @beta Just for internal editor, not recommended for developers.
*/
initVirtualResources(config: EditorResourceItem[]): void {
config.forEach((element) => {
this._virtualPathResourceMap[element.virtualPath] = element;
if (element.dependentAssetMap) {
this._virtualPathResourceMap[element.virtualPath].dependentAssetMap = element.dependentAssetMap;
}
});
}

//-----------------Editor temp solution-----------------
}

/**
Expand Down Expand Up @@ -644,24 +630,23 @@ const rePropName = RegExp(
);

type VirtualPath = string;
type EditorResourceItem = {
export interface VirtualResource {
virtualPath: string;
path: string;
type: string;
dependentAssetMap?: { [key: string]: string };
subpackageName?: string;
params?: Record<string, any>;
};
}
type SubAssetPromiseCallbacks<T> = Record<
// main asset url, ie. "https://***.glb"
string,
Record<
// sub asset url, ie. "textures[0]"
string,
{
// Already resolved or rejected
// Already resolved
resolvedValue?: T;
rejectedValue?: Error;
// Pending
resolve?: (value: T) => void;
reject?: (reason: any) => void;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type { RequestConfig } from "./asset/request";
export { Loader } from "./asset/Loader";
export { ContentRestorer } from "./asset/ContentRestorer";
export { RenderingStatistics } from "./asset/RenderingStatistics";
export { ResourceManager, resourceLoader } from "./asset/ResourceManager";
export { ResourceManager, resourceLoader, type VirtualResource } from "./asset/ResourceManager";
export { AssetPromise } from "./asset/AssetPromise";
export type { LoadItem } from "./asset/LoadItem";
export { AssetType } from "./asset/AssetType";
Expand Down
3 changes: 1 addition & 2 deletions packages/loader/src/ProjectLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ class ProjectLoader extends Loader<void> {
// @ts-ignore
._request<IProject>(item.url, { ...item, type: "json" })
.then((data) => {
// @ts-ignore
engine.resourceManager.initVirtualResources(data.files);
engine.resourceManager.registerVirtualResources(data.files);
return resourceManager
.load<Scene>({ type: AssetType.Scene, url: data.scene })
.onProgress(onTaskCompeteProgress)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class ReflectionParser {

/**
* Apply v2 props to a component/object instance.
* Each prop value is resolved recursively (handling $ref, $type, $class, $entity, $component, $signal).
* Each prop value is resolved recursively (handling $ref, $number, $type, $class, $entity, $component, $signal).
*/
parseProps(instance: any, props?: Record<string, unknown>): Promise<any> {
const promises: Promise<any>[] = [];
Expand All @@ -40,13 +40,21 @@ export class ReflectionParser {
for (let i = 0, n = calls.length; i < n; i++) {
const call = calls[i];
chain = chain.then(() => {
const method = instance?.[call.method];
let target = instance;
if (call.target !== undefined) {
if (!Array.isArray(call.target) || call.target.some((key) => typeof key !== "string" || key.length === 0)) {
return Promise.reject(new Error(`Call "${call.method}" target must be an array of non-empty strings`));
}
for (const key of call.target) target = target?.[key];
}
const method = target?.[call.method];
if (typeof method !== "function") {
return Promise.reject(new Error(`Call target does not have method "${call.method}"`));
const path = call.target?.length ? `${call.target.join(".")}.` : "";
return Promise.reject(new Error(`Call target does not have method "${path}${call.method}"`));
}

return Promise.all((call.args ?? []).map((arg) => this._resolveValue(arg)))
.then((resolvedArgs) => Promise.resolve(method.apply(instance, resolvedArgs)))
.then((resolvedArgs) => Promise.resolve(method.apply(target, resolvedArgs)))
Comment on lines +43 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Block prototype and constructor traversal before invoking nested calls.

Loader-controlled paths can traverse function properties and reach Function, for example target: ["nested", "setValue"] with method: "constructor". Combined with call.result, this permits constructing and invoking arbitrary JavaScript.

Reject constructor, prototype, and __proto__ in both target segments and method names.

Proposed guard
+        const forbiddenKeys = new Set(["constructor", "prototype", "__proto__"]);
         let target = instance;
         if (call.target !== undefined) {
           if (!Array.isArray(call.target) || call.target.some((key) => typeof key !== "string" || key.length === 0)) {
             return Promise.reject(new Error(`Call "${call.method}" target must be an array of non-empty strings`));
           }
+          if (call.target.some((key) => forbiddenKeys.has(key)) || forbiddenKeys.has(call.method)) {
+            return Promise.reject(new Error(`Call "${call.method}" contains a forbidden target key`));
+          }
           for (const key of call.target) target = target?.[key];
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 43 - 57, Update the call traversal and invocation logic in
ReflectionParser to reject the property names "constructor", "prototype", and
"__proto__" in every call.target segment and in call.method before accessing or
invoking them. Return a rejected Promise using the existing validation-error
pattern, while preserving valid nested method resolution and invocation.

.then((result) => {
if (!call.result) return result;
if (result == null || (typeof result !== "object" && typeof result !== "function")) {
Expand All @@ -63,11 +71,13 @@ export class ReflectionParser {
}

/**
* Apply props and calls from the same mutation block without imposing ordering between them.
* Apply props before executing calls from the same mutation block.
*/
parseMutationBlock(target: any, block?: MutationBlock): Promise<any> {
if (!block) return Promise.resolve(target);
return Promise.all([this.parseProps(target, block.props), this.parseCalls(target, block.calls)]).then(() => target);
return this.parseProps(target, block.props)
.then(() => this.parseCalls(target, block.calls))
.then(() => target);
}

/**
Expand All @@ -77,19 +87,24 @@ export class ReflectionParser {
* 1. null/undefined/primitive → passthrough
* 2. Array → recurse each element
* 3. { $ref } → asset reference
* 4. { $type } → polymorphic type construct
* 5. { $class } → registered class constructor
* 6. { $entity } → entity reference by path (flat index + optional children descent)
* 7. { $component } → component reference
* 8. { $signal } → signal binding
* 9. plain object → recurse values (modify originValue in place if exists)
* 4. { $number } → JSON-safe special number
* 5. { $type, $args? } → polymorphic type construct
* 6. { $class } → registered class constructor
* 7. { $entity } → entity reference by path (flat index + optional children descent)
* 8. { $component } → component reference
* 9. { $signal } → signal binding
* 10. plain object → recurse values (modify originValue in place if exists)
*/
private _resolveValue(value: unknown, originValue?: any): Promise<any> {
if (value == null || typeof value !== "object") return Promise.resolve(value);
if (Array.isArray(value)) return Promise.all(value.map((v) => this._resolveValue(v)));

const obj = value as Record<string, unknown>;

if ("$args" in obj && !("$type" in obj)) {
return Promise.reject(new Error("$args requires $type"));
}

// $ref — asset reference (index into refs array)
if ("$ref" in obj) {
const { _context: context } = this;
Expand All @@ -109,12 +124,25 @@ export class ReflectionParser {
});
}

// $type — polymorphic type: construct instance and apply remaining props
if ("$number" in obj) {
if (Object.keys(obj).length !== 1 || obj.$number !== "Infinity") {
return Promise.reject(new Error('$number must be exactly "Infinity"'));
}
return Promise.resolve(Infinity);
}

// $type — polymorphic type: resolve constructor args, construct instance, then apply remaining props
if ("$type" in obj) {
const { $type, ...rest } = obj;
const { $type, $args, ...rest } = obj;
if ($args !== undefined && !Array.isArray($args)) {
return Promise.reject(new Error("$args must be an array when used with $type"));
}
const constructorArgs = Array.isArray($args) ? $args : [];
return this._resolveRegisteredClass($type, "$type").then((Class) => {
const instance = new Class();
return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance;
return Promise.all(constructorArgs.map((arg) => this._resolveValue(arg))).then((args) => {
const instance = new Class(...args);
return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance;
});
});
}

Expand Down
14 changes: 14 additions & 0 deletions packages/loader/src/schema/CommonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,27 @@ export interface ClassRef {
$class: string;
}

/** Registered runtime value with optional recursively-resolved constructor arguments. */
export interface TypeValue {
$type: string;
$args?: unknown[];
[key: string]: unknown;
}

/** JSON-safe encoding for positive infinity. */
export interface SpecialNumberValue {
$number: "Infinity";
}

export interface SignalListener {
target: { $component: ComponentRef };
methodName: string;
args?: unknown[];
}

export interface CallSpec {
/** Optional property path from the mutation root to the method owner. */
target?: string[];
method: string;
args?: unknown[];
result?: MutationBlock;
Expand Down
Loading
Loading