mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-02 19:29:57 -04:00
* fix: two unbounded-cache memory leaks in common and schemas
Two module-level Map caches that never evict and store multi-MB strings
as keys, silently leaking for the entire lifetime of any consumer.
1. packages/common/src/expression.ts — parseDataCache
parseData() was memoized via a module-level parseDataCache keyed by
JSON.stringify(data). replacePlaceholders() calls it with a merged
{ ...schemaNameDefaults, ...variables } object where values may be
arbitrary strings from the caller. Whenever inputs contain base64
(image schemas with embedded data URLs, embedded fonts, large text),
the cache key is a multi-MB JSON string that gets pinned permanently;
every unique inputs state adds its own key, never collected. Parsing
is O(fields) and cheap, so removing the cache is strictly a win.
Regression test: packages/common/__tests__/expression.test.ts
'replacePlaceholders memory safety > does not retain call inputs in
a module-level cache' — runs 30 replacePlaceholders() calls with
unique ~500 KB payloads, captures a V8 heap snapshot via
v8.writeHeapSnapshot, aggregates string nodes >= 200 KB and asserts
the total retained size is below 2 MB. Pre-fix: ~30 MB retained
(FAILS). Post-fix: 0 bytes retained (passes).
2. packages/schemas/src/graphics/image.ts — getCacheKey
getCacheKey(schema, input) returned `${schema.type}${input}`, using
the full base64 bytes of the image as part of the cache key. Every
unique image processed by the PDF render path added a permanent Map
entry whose key byte length matched the image itself.
Replaced with a short fingerprint that samples the total length plus
three 16-char regions (first, middle, last). The middle-region
sample is essential: base64 PNGs share a common header and IEND
trailer, so distinct images of the same size would collide if only
first/last regions were sampled. Middle bytes are pixel data and
differ between distinct images with overwhelming probability. Keys
stay under 80 chars regardless of input size.
Regression tests: packages/schemas/__tests__/image.test.ts
- 'does not pin the full base64 input as a cache key' — asserts
key length < 100 chars. Pre-fix: 139 chars for a minimal PNG and
proportionally more for realistic images (FAILS).
- 'distinguishes different images via the fingerprint' — guards
against future over-shortening of the fingerprint that could
reintroduce collisions between distinct images.
Both leaks were originally identified via a V8 heap-snapshot diff taken
across a UI workload (typing + field tabbing) against a consumer app
with image schemas carrying base64 content. Before the fix, the top two
growing allocations by retained size were multi-MB string entries — one
per module-level cache in this PR — together accounting for hundreds of
MB of retained JS heap in a single 3-iteration run. After the fix, both
string entries disappear from the top 25 growing allocations and
aggregate JS heap is net flat / slightly shrinking across iterations.
No public API change. No behavioral change for consumers. Both caches
were module-local implementation details.
* fix(schemas): harden image cache key with FNV-1a hash; fix stale test comments
Addresses Greptile review on #1426:
- Replace 3-region sampling fingerprint in getCacheKey with an FNV-1a
32-bit hash over the full input. The old first-16 slice was a
constant data-URI prefix for any image of the same MIME type,
contributing no entropy; hashing every byte removes that weakness
at the same O(n) cost without retaining any slice as a Map key.
Key format is now `${type}:${len}:${fnv1a-hex}` (~40 chars).
- Rewrite stale comments in image.test.ts that referred to a
padding/mutation scheme the test never performs, and update the
fingerprint-format comment to match the new hash-based key.
- Add trailing newline to expression.test.ts.
All pre-existing and new tests still pass.
650 lines
28 KiB
TypeScript
650 lines
28 KiB
TypeScript
import { replacePlaceholders } from '../src/expression.js';
|
||
import { SchemaPageArray } from '../src/index.js';
|
||
|
||
describe('replacePlaceholders', () => {
|
||
it('should return content as is if there are no placeholders', () => {
|
||
const content = 'Hello, world!';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should replace placeholders with variables', () => {
|
||
const content = 'Hello, {name}!';
|
||
const variables = { name: 'Alice' };
|
||
const result = replacePlaceholders({ content, variables, schemas: [] });
|
||
expect(result).toBe('Hello, Alice!');
|
||
});
|
||
|
||
it('should evaluate expressions within placeholders', () => {
|
||
const content = 'The sum is {1 + 2}.';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('The sum is 3.');
|
||
});
|
||
|
||
it('should handle date and dateTime placeholders', () => {
|
||
const content = 'Today is {date} and now is {dateTime}.';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
const date = new Date();
|
||
const padZero = (num: number) => String(num).padStart(2, '0');
|
||
const formattedDate = `${date.getFullYear()}/${padZero(date.getMonth() + 1)}/${padZero(
|
||
date.getDate()
|
||
)}`;
|
||
const formattedDateTime = `${formattedDate} ${padZero(date.getHours())}:${padZero(
|
||
date.getMinutes()
|
||
)}`;
|
||
expect(result).toBe(`Today is ${formattedDate} and now is ${formattedDateTime}.`);
|
||
});
|
||
|
||
it('should handle data from schemas', () => {
|
||
const content = 'Schema content: {name}';
|
||
const variables = {};
|
||
const schemas = [
|
||
[
|
||
{
|
||
name: 'name',
|
||
type: 'text',
|
||
content: 'SchemaName',
|
||
readOnly: true,
|
||
},
|
||
],
|
||
] as SchemaPageArray;
|
||
const result = replacePlaceholders({ content, variables, schemas });
|
||
expect(result).toBe('Schema content: SchemaName');
|
||
});
|
||
|
||
it('should prioritize variables over schemas', () => {
|
||
const content = 'Name: {name}';
|
||
const variables = { name: 'VariableName' };
|
||
const schemas = [
|
||
[
|
||
{
|
||
name: 'name',
|
||
type: 'text',
|
||
content: 'SchemaName',
|
||
readOnly: true,
|
||
},
|
||
],
|
||
] as SchemaPageArray;
|
||
const result = replacePlaceholders({ content, variables, schemas });
|
||
expect(result).toBe('Name: VariableName');
|
||
});
|
||
|
||
it('should handle nested placeholders in variables', () => {
|
||
const content = 'Nested variable: {greeting}';
|
||
const variables = { greeting: 'Hello, {name}!' };
|
||
const schemas = [
|
||
[
|
||
{
|
||
name: 'name',
|
||
type: 'text',
|
||
content: 'Bob',
|
||
readOnly: true,
|
||
},
|
||
],
|
||
] as SchemaPageArray;
|
||
const result = replacePlaceholders({ content, variables, schemas });
|
||
expect(result).toBe('Nested variable: Hello, Bob!');
|
||
});
|
||
|
||
it('should return content unchanged when placeholders are invalid', () => {
|
||
const content = 'Invalid placeholder: {name';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('Invalid placeholder: {name');
|
||
});
|
||
|
||
it('should evaluate expressions even if they result in Infinity', () => {
|
||
const content = 'Divide by zero: {1 / 0}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('Divide by zero: Infinity');
|
||
});
|
||
|
||
it('should handle complex expressions', () => {
|
||
const content = 'Result: {Math.max(1, 2, 3)}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('Result: 3');
|
||
});
|
||
|
||
it('should parse JSON strings in variables', () => {
|
||
const content = 'Data: {data.value}';
|
||
const variables = { data: '{"value": "42"}' };
|
||
const result = replacePlaceholders({ content, variables, schemas: [] });
|
||
expect(result).toBe('Data: 42');
|
||
});
|
||
|
||
it('should handle variables of different types', () => {
|
||
const content = 'Number: {num}, Boolean: {bool}, Array: {arr[0]}, Object: {obj.key}';
|
||
const variables = {
|
||
num: 42,
|
||
bool: true,
|
||
arr: ['first', 'second'],
|
||
obj: { key: 'value' },
|
||
};
|
||
const result = replacePlaceholders({ content, variables, schemas: [] });
|
||
expect(result).toBe('Number: 42, Boolean: true, Array: first, Object: value');
|
||
});
|
||
|
||
it('should use content from readOnly schemas', () => {
|
||
const content = 'Content: {readOnlyField}';
|
||
const variables = {};
|
||
const schemas = [
|
||
[
|
||
{
|
||
name: 'readOnlyField',
|
||
type: 'text',
|
||
content: 'ReadOnlyContent',
|
||
readOnly: true,
|
||
},
|
||
],
|
||
] as SchemaPageArray;
|
||
const result = replacePlaceholders({ content, variables, schemas });
|
||
expect(result).toBe('Content: ReadOnlyContent');
|
||
});
|
||
|
||
it('should use empty string for non-readOnly schema content', () => {
|
||
const content = 'Content: {editableField}';
|
||
const variables = {};
|
||
const schemas = [
|
||
[
|
||
{
|
||
name: 'editableField',
|
||
type: 'text',
|
||
content: 'Should not be used',
|
||
readOnly: false,
|
||
},
|
||
],
|
||
] as SchemaPageArray;
|
||
const result = replacePlaceholders({ content, variables, schemas });
|
||
expect(result).toBe('Content: ');
|
||
});
|
||
|
||
it('should allow method chaining on permitted global objects', () => {
|
||
const content = 'Chained: {Math.random().toString()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Math.random() generates a random number, which is then converted to a string using toString()
|
||
const regex = /^Chained: \d+\.\d+$/;
|
||
expect(regex.test(result)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('replacePlaceholders - Security Tests', () => {
|
||
it('should prevent access to __proto__ property', () => {
|
||
const content = 'Proto: {__proto__}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Since __proto__ access is prohibited, the placeholder should remain unchanged
|
||
expect(result).toBe('Proto: {__proto__}');
|
||
});
|
||
|
||
it('should prevent access to constructor property', () => {
|
||
const content = 'Constructor: {constructor}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'constructor' is allowed if defined in context or globals; assuming it's not, placeholder remains
|
||
expect(result).toBe('Constructor: {constructor}');
|
||
});
|
||
|
||
it('should prevent access to prototype property', () => {
|
||
const content = 'Prototype: {prototype}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'prototype' access is prohibited
|
||
expect(result).toBe('Prototype: {prototype}');
|
||
});
|
||
|
||
it('should prevent access to nested prohibited properties', () => {
|
||
const content = 'Nested: {user.__proto__.polluted}';
|
||
const variables = { user: {} };
|
||
const result = replacePlaceholders({ content, variables, schemas: [] });
|
||
// Access to '__proto__' is prohibited; placeholder remains unchanged
|
||
expect(result).toBe('Nested: {user.__proto__.polluted}');
|
||
});
|
||
|
||
it('should prevent use of Function constructor', () => {
|
||
const content = 'Function: {Function("return 42")()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Use of Function constructor is not allowed; placeholder remains unchanged
|
||
expect(result).toBe('Function: {Function("return 42")()}');
|
||
});
|
||
|
||
it('should prevent access to disallowed global variables', () => {
|
||
const content = 'Process: {process.env}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'process' is not in allowedGlobals; placeholder remains unchanged
|
||
expect(result).toBe('Process: {process.env}');
|
||
});
|
||
|
||
it('should prevent prototype pollution via JSON.parse', () => {
|
||
const content = 'Polluted: {JSON.parse(\'{"__proto__":{"polluted":true}}\').polluted}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Even if 'polluted' is accessed, the prototype is not polluted, so undefined is returned.
|
||
expect(result).toBe('Polluted: undefined');
|
||
});
|
||
|
||
it('should prevent accessing nested prohibited properties in functions', () => {
|
||
const content = 'Access: {( () => { return this.constructor } )()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Attempting to access 'constructor' via 'this' should be prohibited; placeholder remains unchanged
|
||
expect(result).toBe('Access: {( () => { return this.constructor } )()}');
|
||
});
|
||
|
||
it('should prevent accessing global objects not in allowedGlobals', () => {
|
||
const content = 'Global: {global}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'global' is not in allowedGlobals; placeholder remains unchanged
|
||
expect(result).toBe('Global: {global}');
|
||
});
|
||
|
||
it('should prevent accessing Object constructor via allowed globals', () => {
|
||
const content = 'ObjectConstructor: {Object.constructor}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Accessing 'constructor' of 'Object' is prohibited
|
||
expect(result).toBe('ObjectConstructor: {Object.constructor}');
|
||
});
|
||
|
||
it('should prevent accessing Function from allowed globals', () => {
|
||
const content = 'FunctionAccess: {Function("return 42")()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'Function' is not in allowedGlobals, so this should fail
|
||
expect(result).toBe('FunctionAccess: {Function("return 42")()}');
|
||
});
|
||
|
||
it('should prevent accessing nested properties via allowed globals', () => {
|
||
const content = 'NestedAccess: {Math.__proto__.polluted}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Accessing '__proto__' of allowed global 'Math' is prohibited
|
||
expect(result).toBe('NestedAccess: {Math.__proto__.polluted}');
|
||
});
|
||
|
||
it('should prevent execution of arbitrary code via ternary operator', () => {
|
||
const content = 'ArbitraryCode: {true ? (() => { return "Hacked" })() : "Safe"}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Execution of arbitrary functions is not allowed; placeholder remains unchanged
|
||
expect(result).toBe('ArbitraryCode: {true ? (() => { return "Hacked" })() : "Safe"}');
|
||
});
|
||
|
||
it('should handle attempts to override context variables', () => {
|
||
const content = 'Override: {date = "Hacked"} {date}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Assignment operations are not supported; placeholders remain unchanged
|
||
const date = new Date();
|
||
const padZero = (num: number) => String(num).padStart(2, '0');
|
||
const dateFmt = `${date.getFullYear()}/${padZero(date.getMonth() + 1)}/${padZero(
|
||
date.getDate()
|
||
)}`;
|
||
expect(result).toBe(`Override: {date = "Hacked"} ${dateFmt}`);
|
||
});
|
||
|
||
it('should prevent using eval-like expressions', () => {
|
||
const content = 'Eval: {eval("2 + 2")';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'eval' is not in allowedGlobals; placeholder remains unchanged
|
||
expect(result).toBe('Eval: {eval("2 + 2")');
|
||
});
|
||
|
||
it('should prevent accessing undefined variables', () => {
|
||
const content = 'Undefined: {undefinedVar}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'undefinedVar' is not defined; placeholder remains unchanged
|
||
expect(result).toBe('Undefined: {undefinedVar}');
|
||
});
|
||
|
||
it('should prevent accessing nested properties of undefined variables', () => {
|
||
const content = 'NestedUndefined: {user.name}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// 'user' is undefined; accessing 'name' should fail and placeholder remains unchanged
|
||
expect(result).toBe('NestedUndefined: {user.name}');
|
||
});
|
||
|
||
it('should prevent accessing nested prohibited properties in objects', () => {
|
||
const content = 'Nested: {user.__proto__.polluted}';
|
||
const variables = { user: {} };
|
||
const result = replacePlaceholders({ content, variables, schemas: [] });
|
||
// Since access to '__proto__' is prohibited, the placeholder remains unchanged.
|
||
expect(result).toBe('Nested: {user.__proto__.polluted}');
|
||
});
|
||
|
||
it('should prevent using Function constructor', () => {
|
||
const content = 'Function: {Function("return 42")()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Since 'Function' is not included in allowedGlobals, the placeholder remains unchanged.
|
||
expect(result).toBe('Function: {Function("return 42")()}');
|
||
});
|
||
});
|
||
|
||
describe('replacePlaceholders - Comparison Operators Tests', () => {
|
||
it('should evaluate expressions with == operator', () => {
|
||
const content = 'Equals: {1 == 1}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('Equals: true');
|
||
});
|
||
|
||
it('should evaluate expressions with != operator', () => {
|
||
const content = 'NotEquals: {1 != 2}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('NotEquals: true');
|
||
});
|
||
|
||
it('should evaluate expressions with === operator', () => {
|
||
const content = 'StrictEquals: {1 === 1}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('StrictEquals: true');
|
||
});
|
||
|
||
it('should evaluate expressions with !== operator', () => {
|
||
const content = 'StrictNotEquals: {1 !== "1"}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('StrictNotEquals: true');
|
||
});
|
||
|
||
it('should evaluate expressions with < operator', () => {
|
||
const content = 'LessThan: {1 < 2}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('LessThan: true');
|
||
});
|
||
|
||
it('should evaluate expressions with > operator', () => {
|
||
const content = 'GreaterThan: {2 > 1}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('GreaterThan: true');
|
||
});
|
||
|
||
it('should evaluate expressions with <= operator', () => {
|
||
const content = 'LessThanOrEquals: {1 <= 1}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('LessThanOrEquals: true');
|
||
});
|
||
|
||
it('should evaluate expressions with >= operator', () => {
|
||
const content = 'GreaterThanOrEquals: {2 >= 1}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('GreaterThanOrEquals: true');
|
||
});
|
||
|
||
it('should handle complex expressions with comparison operators', () => {
|
||
const content = 'Complex: {1 + 2 > 2 && 4 - 1 < 5}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe('Complex: true');
|
||
});
|
||
|
||
it('should prevent execution of arbitrary code via comparison operators', () => {
|
||
const content = 'ArbitraryCode: {1 < (() => { return "Hacked" })()}';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Execution of arbitrary functions is not allowed; placeholder remains unchanged
|
||
expect(result).toBe('ArbitraryCode: {1 < (() => { return "Hacked" })()}');
|
||
});
|
||
});
|
||
|
||
describe('replacePlaceholders - XSS Vulnerability Prevention Tests', () => {
|
||
it('should prevent XSS via Object.getOwnPropertyDescriptor and Object.getPrototypeOf (CVE payload 1)', () => {
|
||
const content = '{ ((f, g) => f(g(Object), "constructor").value)(Object.getOwnPropertyDescriptor, Object.getPrototypeOf)("alert(location)")() }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// The dangerous expression should not be evaluated and should return as-is
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent XSS via object property assignment (CVE payload 2)', () => {
|
||
const content = '{ { f: Object.getOwnPropertyDescriptor }.f({ g: Object.getPrototypeOf }.g(Object), "constructor").value("alert(location)")() }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// The dangerous expression should not be evaluated and should return as-is
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent direct access to Object.getOwnPropertyDescriptor', () => {
|
||
const content = '{ Object.getOwnPropertyDescriptor(Object, "constructor") }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent direct access to Object.getPrototypeOf', () => {
|
||
const content = '{ Object.getPrototypeOf(Object) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to Object.setPrototypeOf', () => {
|
||
const content = '{ Object.setPrototypeOf({}, null) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to Object.defineProperty', () => {
|
||
const content = '{ Object.defineProperty({}, "prop", { value: 42 }) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to Object.defineProperties', () => {
|
||
const content = '{ Object.defineProperties({}, { prop: { value: 42 } }) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to Object.getOwnPropertyNames', () => {
|
||
const content = '{ Object.getOwnPropertyNames(Object) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to Object.getOwnPropertySymbols', () => {
|
||
const content = '{ Object.getOwnPropertySymbols(Object) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Direct access to dangerous method should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should allow safe Object methods', () => {
|
||
// Test Object.keys
|
||
const keysContent = '{ Object.keys({ a: 1, b: 2 }) }';
|
||
const keysResult = replacePlaceholders({ content: keysContent, variables: {}, schemas: [] });
|
||
expect(keysResult).toBe('a,b');
|
||
|
||
// Test Object.values
|
||
const valuesContent = '{ Object.values({ a: 1, b: 2 }) }';
|
||
const valuesResult = replacePlaceholders({ content: valuesContent, variables: {}, schemas: [] });
|
||
expect(valuesResult).toBe('1,2');
|
||
|
||
// Test Object.entries
|
||
const entriesContent = '{ Object.entries({ a: 1 })[0] }';
|
||
const entriesResult = replacePlaceholders({ content: entriesContent, variables: {}, schemas: [] });
|
||
expect(entriesResult).toBe('a,1');
|
||
|
||
// Test safe Object.assign
|
||
const assignContent = '{ Object.assign({}, { a: 1 }, { b: 2 }).a }';
|
||
const assignResult = replacePlaceholders({ content: assignContent, variables: {}, schemas: [] });
|
||
expect(assignResult).toBe('1'); // Safe assign should work
|
||
});
|
||
|
||
it('should prevent complex XSS attempts via nested function calls', () => {
|
||
const content = '{ [].map.call("abc", Object.getOwnPropertyDescriptor) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Complex attempts to access dangerous functions should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent Function constructor access via constructor property', () => {
|
||
const content = '{ "".constructor.constructor("alert(1)")() }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Attempts to access Function constructor should be blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent prototype pollution via Object.assign and __lookupGetter__', () => {
|
||
const content = '{ { assign: Object.assign }.assign({ f: {}.__lookupGetter__("__proto__") }.f(), { polluted: "yes" }) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// The dangerous expression should not be evaluated due to __lookupGetter__ being blocked
|
||
expect(result).toBe(content);
|
||
// Verify that prototype is not polluted
|
||
expect(({} as any).polluted).toBeUndefined();
|
||
});
|
||
|
||
it('should prevent access to __lookupGetter__', () => {
|
||
const content = '{ {}.__lookupGetter__("__proto__") }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to __lookupSetter__', () => {
|
||
const content = '{ {}.__lookupSetter__("__proto__") }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to __defineGetter__', () => {
|
||
const content = '{ {}.__defineGetter__("test", () => "hacked") }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should prevent access to __defineSetter__', () => {
|
||
const content = '{ {}.__defineSetter__("test", () => {}) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should allow safe Object.assign but prevent prototype pollution', () => {
|
||
const content = '{ Object.assign({}, { a: 1 }) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Safe assign should work
|
||
expect(result).toBe('[object Object]');
|
||
});
|
||
|
||
it('should prevent prototype pollution via Object.assign', () => {
|
||
const pollutionContent = '{ Object.assign({}, { "__proto__": { polluted: "yes" } }) }';
|
||
const result = replacePlaceholders({ content: pollutionContent, variables: {}, schemas: [] });
|
||
// Should execute but not pollute prototype
|
||
expect(result).toBe('[object Object]');
|
||
expect(({} as any).polluted).toBeUndefined();
|
||
|
||
// Test with constructor
|
||
const constructorContent = '{ Object.assign({}, { "constructor": { polluted: "yes" } }) }';
|
||
const result2 = replacePlaceholders({ content: constructorContent, variables: {}, schemas: [] });
|
||
expect(result2).toBe('[object Object]');
|
||
expect(({} as any).constructor.polluted).toBeUndefined();
|
||
});
|
||
|
||
it('should no longer allow Object.create due to security concerns', () => {
|
||
const content = '{ Object.create(null) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Object.create is now blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should no longer allow Object.freeze due to security concerns', () => {
|
||
const content = '{ Object.freeze({}) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Object.freeze is now blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
|
||
it('should no longer allow Object.seal due to security concerns', () => {
|
||
const content = '{ Object.seal({}) }';
|
||
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
|
||
// Object.seal is now blocked
|
||
expect(result).toBe(content);
|
||
});
|
||
});
|
||
|
||
describe('replacePlaceholders memory safety', () => {
|
||
// Regression guard: before the fix, `parseData` memoized results in a
|
||
// module-level `parseDataCache` keyed by `JSON.stringify(data)` that was
|
||
// never evicted. Every unique inputs state added a permanent Map key
|
||
// whose byte length matched the input byte length, so any consumer
|
||
// passing varying large string values (image base64 in schema content,
|
||
// embedded fonts, large text) leaked memory for the app's lifetime.
|
||
//
|
||
// This test stress-tests the API with many unique large inputs and
|
||
// asserts that no multi-hundred-KB strings are retained afterwards. It
|
||
// uses `v8.writeHeapSnapshot` to capture ground truth from V8 instead
|
||
// of relying on `process.memoryUsage()` (which is too coarse and noisy
|
||
// to catch a cache that grows by tens of MB per burst).
|
||
//
|
||
// The test is deterministic in direction: pre-fix, retained "big
|
||
// string" bytes is O(inputs × inputSize) ≈ 15 MB, far above the
|
||
// threshold. Post-fix, retained is effectively zero (the strings are
|
||
// temporaries freed when the call returns).
|
||
it('does not retain call inputs in a module-level cache', () => {
|
||
const schemas: SchemaPageArray = [
|
||
[
|
||
{
|
||
name: 'blob',
|
||
type: 'text',
|
||
content: '',
|
||
position: { x: 0, y: 0 },
|
||
width: 100,
|
||
height: 10,
|
||
},
|
||
],
|
||
] as unknown as SchemaPageArray;
|
||
|
||
// 30 unique payloads × ~500 KB each. If the fix regresses, at least
|
||
// 15 MB of string data will be pinned as parseDataCache keys. The
|
||
// content MUST contain a placeholder (`{blob}`) — otherwise
|
||
// replacePlaceholders short-circuits and parseData is never called.
|
||
const PAYLOAD_COUNT = 30;
|
||
const PAYLOAD_SIZE = 500_000;
|
||
for (let i = 0; i < PAYLOAD_COUNT; i++) {
|
||
replacePlaceholders({
|
||
content: 'value: {blob}',
|
||
variables: { blob: `${i}-${'x'.repeat(PAYLOAD_SIZE)}` },
|
||
schemas,
|
||
});
|
||
}
|
||
|
||
// Force full GC if `--expose-gc` is available. If not, V8 will still
|
||
// have collected most transients by the time writeHeapSnapshot runs,
|
||
// and the threshold is generous enough that transient tails don't
|
||
// matter. On CI where --expose-gc is absent the test can still
|
||
// distinguish +15 MB (leak) from <1 MB (fixed).
|
||
const gc = (globalThis as { gc?: () => void }).gc;
|
||
if (gc) gc();
|
||
|
||
// v8.writeHeapSnapshot is available in all Node versions the pdfme
|
||
// monorepo supports. The snapshot JSON can be tens of MB; we parse it
|
||
// once and discard.
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const v8: typeof import('v8') = require('v8');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const fs: typeof import('fs') = require('fs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const os: typeof import('os') = require('os');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const path: typeof import('path') = require('path');
|
||
|
||
const snapPath = path.join(
|
||
os.tmpdir(),
|
||
`pdfme-expression-memtest-${process.pid}-${Date.now()}.heapsnapshot`,
|
||
);
|
||
v8.writeHeapSnapshot(snapPath);
|
||
const rawSnap = fs.readFileSync(snapPath, 'utf8');
|
||
fs.unlinkSync(snapPath);
|
||
const snap = JSON.parse(rawSnap) as {
|
||
nodes: number[];
|
||
snapshot: { meta: { node_fields: string[]; node_types: string[][] } };
|
||
};
|
||
const fieldCount = snap.snapshot.meta.node_fields.length;
|
||
const typeFieldIdx = snap.snapshot.meta.node_fields.indexOf('type');
|
||
const selfSizeIdx = snap.snapshot.meta.node_fields.indexOf('self_size');
|
||
const typeNames = snap.snapshot.meta.node_types[typeFieldIdx];
|
||
|
||
// Sum self_size of all 'string' nodes whose size exceeds 200 KB. A
|
||
// healthy post-fix run has ~0 such nodes (strings are freed after
|
||
// each call). A leaking pre-fix run has at least PAYLOAD_COUNT
|
||
// nodes totalling >= PAYLOAD_COUNT * PAYLOAD_SIZE bytes.
|
||
const MIN_STRING_BYTES = 200_000;
|
||
let retainedLargeStringBytes = 0;
|
||
for (let i = 0; i < snap.nodes.length; i += fieldCount) {
|
||
if (typeNames[snap.nodes[i + typeFieldIdx]] !== 'string') continue;
|
||
const size = snap.nodes[i + selfSizeIdx];
|
||
if (size >= MIN_STRING_BYTES) retainedLargeStringBytes += size;
|
||
}
|
||
|
||
// Generous threshold: healthy is ~0, leak is ~15 MB. 2 MB cleanly
|
||
// separates the two without being sensitive to unrelated large
|
||
// strings (e.g. inline source maps in test fixtures).
|
||
expect(retainedLargeStringBytes).toBeLessThan(2_000_000);
|
||
}, 60_000);
|
||
});
|