diff --git a/javascript/packages/fory/lib/fory.ts b/javascript/packages/fory/lib/fory.ts index d26b6b6fa7..56f77277b3 100644 --- a/javascript/packages/fory/lib/fory.ts +++ b/javascript/packages/fory/lib/fory.ts @@ -40,9 +40,15 @@ export default class { anySerializer: Serializer; typeMeta = TypeMeta; config: Config; + depth = 0; + maxDepth: number; constructor(config?: Partial) { this.config = this.initConfig(config); + this.maxDepth = config?.maxDepth ?? 50; + if (this.maxDepth < 2) { + throw new Error(`maxDepth must be >= 2 but got ${this.maxDepth}`); + } this.binaryReader = new BinaryReader(this.config); this.binaryWriter = new BinaryWriter(this.config); this.referenceResolver = new ReferenceResolver(this.binaryReader); @@ -57,6 +63,7 @@ export default class { return { refTracking: config?.refTracking !== null ? Boolean(config?.refTracking) : null, useSliceString: Boolean(config?.useSliceString), + maxDepth: config?.maxDepth, hooks: config?.hooks || {}, compatible: Boolean(config?.compatible), }; @@ -66,6 +73,34 @@ export default class { return this.config.compatible === true; } + incReadDepth(): void { + this.depth++; + if (this.depth > this.maxDepth) { + throw new Error( + `Deserialization depth limit exceeded: ${this.depth} > ${this.maxDepth}. ` + + "The data may be malicious, or increase maxDepth if needed." + ); + } + } + + decReadDepth(): void { + this.depth--; + } + + private resetRead(): void { + this.referenceResolver.resetRead(); + this.typeMetaResolver.resetRead(); + this.metaStringResolver.resetRead(); + this.depth = 0; + } + + private resetWrite(): void { + this.binaryWriter.reset(); + this.referenceResolver.resetWrite(); + this.metaStringResolver.resetWrite(); + this.typeMetaResolver.resetWrite(); + } + registerSerializer(constructor: new () => T, customSerializer: CustomSerializer): { serializer: Serializer; serialize(data: InputType | null): PlatformBuffer; @@ -141,10 +176,8 @@ export default class { } deserialize(bytes: Uint8Array, serializer: Serializer = this.anySerializer): T | null { - this.referenceResolver.reset(); + this.resetRead(); this.binaryReader.reset(bytes); - this.typeMetaResolver.reset(); - this.metaStringResolver.reset(); const bitmap = this.binaryReader.readUint8(); if ((bitmap & ConfigFlags.isNullFlag) === ConfigFlags.isNullFlag) { return null; @@ -162,16 +195,13 @@ export default class { private serializeInternal(data: T, serializer: Serializer) { try { - this.binaryWriter.reset(); + this.resetWrite(); } catch (e) { if (e instanceof OwnershipError) { throw new Error("Permission denied. To release the serialization ownership, you must call the dispose function returned by serializeVolatile."); } throw e; } - this.referenceResolver.reset(); - this.metaStringResolver.reset(); - this.typeMetaResolver.reset(); let bitmap = 0; if (data === null) { bitmap |= ConfigFlags.isNullFlag; diff --git a/javascript/packages/fory/lib/gen/serializer.ts b/javascript/packages/fory/lib/gen/serializer.ts index 912ce095b2..154aae4814 100644 --- a/javascript/packages/fory/lib/gen/serializer.ts +++ b/javascript/packages/fory/lib/gen/serializer.ts @@ -198,28 +198,38 @@ export abstract class BaseSerializerGenerator implements SerializerGenerator { } readNoRef(assignStmt: (v: string) => string, refState: string): string { + const result = this.scope.uniqueName("result"); return ` + fory.incReadDepth(); ${this.readTypeInfo()} - ${this.read(assignStmt, refState)}; + let ${result}; + ${this.read(v => `${result} = ${v}`, refState)}; + fory.decReadDepth(); + ${assignStmt(result)}; `; } readRefWithoutTypeInfo(assignStmt: (v: string) => string): string { const refFlag = this.scope.uniqueName("refFlag"); + const result = this.scope.uniqueName("result"); return ` const ${refFlag} = ${this.builder.reader.readInt8()}; + let ${result}; switch (${refFlag}) { case ${RefFlags.NotNullValueFlag}: case ${RefFlags.RefValueFlag}: - ${this.read(assignStmt, `${refFlag} === ${RefFlags.RefValueFlag}`)} + fory.incReadDepth(); + ${this.read(v => `${result} = ${v}`, `${refFlag} === ${RefFlags.RefValueFlag}`)} + fory.decReadDepth(); break; case ${RefFlags.RefFlag}: - ${assignStmt(this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32()))} + ${result} = ${this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32())}; break; case ${RefFlags.NullFlag}: - ${assignStmt("null")} + ${result} = null; break; } + ${assignStmt(result)}; `; } diff --git a/javascript/packages/fory/lib/metaStringResolver.ts b/javascript/packages/fory/lib/metaStringResolver.ts index 50f4afc098..ccf0ca6d04 100644 --- a/javascript/packages/fory/lib/metaStringResolver.ts +++ b/javascript/packages/fory/lib/metaStringResolver.ts @@ -108,4 +108,15 @@ export class MetaStringResolver { }); this.dynamicNameId = 0; } + + resetRead() { + // No state to reset for read operation + } + + resetWrite() { + this.disposeMetaStringBytes.forEach((x) => { + x.dynamicWriteStringId = -1; + }); + this.dynamicNameId = 0; + } } diff --git a/javascript/packages/fory/lib/referenceResolver.ts b/javascript/packages/fory/lib/referenceResolver.ts index ec919c966a..0602151521 100644 --- a/javascript/packages/fory/lib/referenceResolver.ts +++ b/javascript/packages/fory/lib/referenceResolver.ts @@ -35,6 +35,14 @@ export class ReferenceResolver { this.writeObjects = new Map(); } + resetRead() { + this.readObjects = []; + } + + resetWrite() { + this.writeObjects = new Map(); + } + getReadObject(refId: number) { return this.readObjects[refId]; } diff --git a/javascript/packages/fory/lib/type.ts b/javascript/packages/fory/lib/type.ts index 983c4797ce..52f320869f 100644 --- a/javascript/packages/fory/lib/type.ts +++ b/javascript/packages/fory/lib/type.ts @@ -264,6 +264,7 @@ export interface Config { hps?: Hps; refTracking: boolean | null; useSliceString: boolean; + maxDepth?: number; hooks: { afterCodeGenerated?: (code: string) => string; }; diff --git a/javascript/packages/fory/lib/typeMetaResolver.ts b/javascript/packages/fory/lib/typeMetaResolver.ts index ad13ea22a5..b60531d7a3 100644 --- a/javascript/packages/fory/lib/typeMetaResolver.ts +++ b/javascript/packages/fory/lib/typeMetaResolver.ts @@ -121,4 +121,16 @@ export class TypeMetaResolver { this.dynamicTypeId = 0; this.typeMeta = []; } + + resetRead() { + this.typeMeta = []; + } + + resetWrite() { + this.disposeTypeInfo.forEach((x) => { + x.dynamicTypeId = -1; + }); + this.disposeTypeInfo = []; + this.dynamicTypeId = 0; + } } diff --git a/javascript/test/depthLimit.test.ts b/javascript/test/depthLimit.test.ts new file mode 100644 index 0000000000..690a0e0b09 --- /dev/null +++ b/javascript/test/depthLimit.test.ts @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Fory, { Type } from '../packages/fory/index'; +import { describe, expect, test } from '@jest/globals'; + +describe('depth-limit', () => { + describe('configuration', () => { + test('should have default maxDepth of 50', () => { + const fory = new Fory(); + expect(fory.maxDepth).toBe(50); + }); + + test('should accept custom maxDepth', () => { + const fory = new Fory({ maxDepth: 100 }); + expect(fory.maxDepth).toBe(100); + }); + + test('should initialize depth counter to 0', () => { + const fory = new Fory(); + expect(fory.depth).toBe(0); + }); + + test('should reject maxDepth < 2', () => { + expect(() => new Fory({ maxDepth: 1 })).toThrow( + 'maxDepth must be >= 2 but got 1' + ); + }); + + test('should reject maxDepth = 0', () => { + expect(() => new Fory({ maxDepth: 0 })).toThrow( + 'maxDepth must be >= 2' + ); + }); + + test('should reject negative maxDepth', () => { + expect(() => new Fory({ maxDepth: -5 })).toThrow( + 'maxDepth must be >= 2' + ); + }); + }); + + describe('depth operations', () => { + test('should have incReadDepth method', () => { + const fory = new Fory(); + expect(typeof fory.incReadDepth).toBe('function'); + }); + + test('should have decReadDepth method', () => { + const fory = new Fory(); + expect(typeof fory.decReadDepth).toBe('function'); + }); + + test('incReadDepth should increment depth', () => { + const fory = new Fory({ maxDepth: 100 }); + expect(fory.depth).toBe(0); + fory.incReadDepth(); + expect(fory.depth).toBe(1); + fory.incReadDepth(); + expect(fory.depth).toBe(2); + }); + + test('decReadDepth should decrement depth', () => { + const fory = new Fory({ maxDepth: 100 }); + fory.incReadDepth(); + fory.incReadDepth(); + expect(fory.depth).toBe(2); + fory.decReadDepth(); + expect(fory.depth).toBe(1); + fory.decReadDepth(); + expect(fory.depth).toBe(0); + }); + + test('incReadDepth should throw when depth exceeds limit', () => { + const fory = new Fory({ maxDepth: 2 }); + fory.incReadDepth(); // depth = 1 + fory.incReadDepth(); // depth = 2 + expect(() => fory.incReadDepth()).toThrow( + 'Deserialization depth limit exceeded: 3 > 2' + ); + }); + + test('depth error message should mention limit and hint', () => { + const fory = new Fory({ maxDepth: 5 }); + try { + for (let i = 0; i < 6; i++) { + fory.incReadDepth(); + } + throw new Error('Should have thrown depth limit error'); + } catch (e) { + expect(e.message).toContain('Deserialization depth limit exceeded'); + expect(e.message).toContain('5'); + expect(e.message).toContain('increase maxDepth if needed'); + } + }); + }); + + describe('deserialization with depth tracking', () => { + test('should deserialize simple struct without depth error', () => { + const fory = new Fory({ maxDepth: 50 }); + const typeInfo = Type.struct({ + typeName: 'simple.struct', + }, { + a: Type.int32(), + b: Type.string(), + }); + + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + const data = { a: 42, b: 'hello' }; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be reset after deserialization + }); + + test('should deserialize nested struct within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const nestedType = Type.struct({ + typeName: 'nested.outer', + }, { + value: Type.int32(), + inner: Type.struct({ + typeName: 'nested.inner', + }, { + innerValue: Type.int32(), + }).setNullable(true), + }); + + const { serialize, deserialize } = fory.registerSerializer(nestedType); + const data = { value: 1, inner: { innerValue: 2 } }; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be reset after deserialization + }); + + test('should deserialize array of primitives within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const arrayType = Type.array(Type.int32()); + + const { serialize, deserialize } = fory.registerSerializer(arrayType); + const data = [1, 2, 3, 4, 5]; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be 0 after deserialization + }); + + test('should deserialize map within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const mapType = Type.map(Type.string(), Type.int32()); + + const { serialize, deserialize } = fory.registerSerializer(mapType); + const data = new Map([['a', 1], ['b', 2]]); + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be 0 after deserialization + }); + + test('should reset depth at start of each deserialization', () => { + const fory = new Fory({ maxDepth: 50 }); + const typeInfo = Type.struct({ + typeName: 'test.reset', + }, { + a: Type.int32(), + }); + + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + deserialize(serialize({ a: 1 })); + + // Depth will be reset at the start of resetRead() call + expect(fory.depth).toBe(0); + + deserialize(serialize({ a: 2 })); + expect(fory.depth).toBe(0); + }); + }); + + describe('cross-serialization depth limits', () => { + test('should allow serialize with high limit and deserialize with low limit', () => { + const serializeType = Type.struct({ + typeName: 'cross.test', + }, { + value: Type.int32(), + next: Type.struct({ + typeName: 'cross.inner', + }, { + innerValue: Type.int32(), + }).setNullable(true), + }); + + // Serialize with high limit + const forySerialize = new Fory({ maxDepth: 100 }); + const { serialize } = forySerialize.registerSerializer(serializeType); + + const data = { value: 1, next: { innerValue: 2 } }; + const serialized = serialize(data); + + // Deserialize with different instance + const foryDeserialize = new Fory({ maxDepth: 50 }); + const { deserialize } = foryDeserialize.registerSerializer(serializeType); + + const deserialized = deserialize(serialized); + expect(deserialized).toEqual(data); + }); + + test('should have independent depth tracking per Fory instance', () => { + const fory1 = new Fory({ maxDepth: 50 }); + const fory2 = new Fory({ maxDepth: 100 }); + + fory1.incReadDepth(); + fory1.incReadDepth(); + expect(fory1.depth).toBe(2); + + fory2.incReadDepth(); + expect(fory2.depth).toBe(1); + + // Both instances have independent depth counters + expect(fory1.depth).toBe(2); + expect(fory2.depth).toBe(1); + }); + }); + + describe('error scenarios', () => { + test('error message should include helpful suggestion', () => { + const fory = new Fory({ maxDepth: 2 }); + try { + for (let i = 0; i < 3; i++) { + fory.incReadDepth(); + } + throw new Error('Should have thrown'); + } catch (e) { + expect(e.message).toContain('increase maxDepth if needed'); + } + }); + + test('should recover after depth error when deserialization resets depth', () => { + const typeInfo = Type.struct({ + typeName: 'test.recovery', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 50 }); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + + // First deserialization + let result = deserialize(serialize({ a: 1 })); + expect(result).toEqual({ a: 1 }); + expect(fory.depth).toBe(0); + + // Second deserialization should also work (depth reset) + result = deserialize(serialize({ a: 2 })); + expect(result).toEqual({ a: 2 }); + expect(fory.depth).toBe(0); + }); + }); + + describe('edge cases', () => { + test('should handle maxDepth exactly equal to required depth', () => { + const typeInfo = Type.struct({ + typeName: 'edge.exact', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 2 }); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + // Should deserialize without error + const result = deserialize(serialize({ a: 42 })); + expect(result).toEqual({ a: 42 }); + }); + + test('should handle large maxDepth values', () => { + const fory = new Fory({ maxDepth: 10000 }); + expect(fory.maxDepth).toBe(10000); + }); + + test('should handle minimum valid maxDepth of 2', () => { + const typeInfo = Type.struct({ + typeName: 'edge.min', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 2 }); + expect(fory.maxDepth).toBe(2); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + // Should deserialize without error + const result = deserialize(serialize({ a: 42 })); + expect(result).toEqual({ a: 42 }); + }); + }); + + describe('configuration with other options', () => { + test('should work with refTracking enabled', () => { + const fory = new Fory({ + maxDepth: 50, + refTracking: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with compatible mode enabled', () => { + const fory = new Fory({ + maxDepth: 50, + compatible: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with useSliceString option', () => { + const fory = new Fory({ + maxDepth: 50, + useSliceString: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with all options combined', () => { + const fory = new Fory({ + maxDepth: 100, + refTracking: true, + compatible: true, + useSliceString: true, + }); + expect(fory.maxDepth).toBe(100); + }); + }); +});