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
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@
"ajv-errors": "~3.0.0",
"ajv-formats": "~2.1.1",
"es-aggregate-error": "^1.0.7",
"expr-eval-fork": "^3.0.1",
"jsonpath-plus": "^10.3.0",
"lodash": "~4.17.23",
"lodash.topath": "^4.5.2",
"minimatch": "3.1.2",
"nimma": "0.2.3",
"pony-cause": "^1.1.1",
"simple-eval": "1.0.1",
"tslib": "^2.8.1"
},
"devDependencies": {
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/runner/utils/__tests__/message.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { message } from '../message';

const mockFs = {
existsSync: jest.fn().mockReturnValue(false),
unlinkSync: jest.fn(),
};

const fs = mockFs;

describe('message util', () => {
test('interpolates correctly', () => {
const template = 'oops... "{{property}}" is missing;error: {{error}}';
Expand Down Expand Up @@ -67,4 +74,24 @@ describe('message util', () => {
}),
).toEqual('missing :(');
});

test('does not allow arbitrary code execution', () => {
const template =
'#{{property.toString.constructor.constructor("return process")().mainModule.require("fs").writeFileSync("danger.txt", "")}}';

const result = message(template, {
property: 'description',
error: 'expected property to be truthy',
path: '',
description: null,
value: void 0,
});

// Ensure the dangerous expression is not evaluated
expect(result).toEqual('');

// Check if the file was created
expect(fs.existsSync('danger.txt')).toBe(false);
expect(fs.unlinkSync).not.toHaveBeenCalled();
});
});
26 changes: 26 additions & 0 deletions packages/core/src/utils/__tests__/replacer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,29 @@ describe('Replacer', () => {
).toThrow();
});
});

describe('Replacer Security Tests', () => {
let replacer: Replacer<Record<string, unknown>>;

beforeEach(() => {
replacer = new Replacer(1);
});

it('should block dangerous patterns', () => {
const input = '#{process.mainModule.require("fs").writeFileSync("test.txt", "content")}';
const result = replacer.print(input, {});
expect(result).toBe('');
});

it('should evaluate safe expressions', () => {
const input = '#{toUpperCase("hello")}';
const result = replacer.print(input, {});
expect(result).toBe('HELLO');
});

it('should handle invalid expressions gracefully', () => {
const input = '#{invalidFunction()}';
const result = replacer.print(input, {});
expect(result).toBe('');
});
});
61 changes: 47 additions & 14 deletions packages/core/src/utils/replacer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Dictionary } from '@stoplight/types';
import _eval from 'simple-eval';
import { Parser, Value } from 'expr-eval-fork';

export type Transformer<V = Record<string, unknown>> = (this: V, ...args: unknown[]) => string;

Expand All @@ -18,28 +18,61 @@ export class Replacer<V extends Record<string, unknown>> {
}

public print(input: string, values: V): string {
return input.replace(this.regex, (substr, identifier, index) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const parser = new Parser();

const functions = parser.functions as Record<string, (...args: Value[]) => Value>;
functions.toUpperCase = (value: Value): Value => {
return String(value).toUpperCase();
};

functions.concat = (...args: Value[]): Value => {
return args.map(String).join('');
};

Object.entries(this.functions).forEach(([name, fn]) => {
functions[name] = fn.bind(values) as (...args: Value[]) => Value;
});

const context = values as unknown as Record<string, Value>;

return input.replace(this.regex, (_substr, identifier: string, index: number) => {
if (index < 0 || index >= input.length) {
return '';
}

const shouldEvaluate = input[index] === '#';

if (shouldEvaluate) {
return String(
_eval(identifier, {
...Object.entries(this.functions).reduce((fns, [name, fn]) => {
fns[name] = fn.bind(values);
return fns;
}, {}),
...values,
}),
);
let expression = identifier.trim();

// Block dangerous patterns
if (/constructor|process|require|global|mainModule|fs|child_process/.test(expression)) {
// eslint-disable-next-line no-console
console.error('Disallowed expression detected:', expression);
return '';
}

// Preprocess the expression
try {
expression = expression
.replace(/(\S+)\.toUpperCase\(\)/g, 'toUpperCase($1)')
.replace(/\s*\+\s*/g, ',')
.replace(/^(.+)$/, 'concat($1)');

// Evaluate the expression
return String(parser.evaluate(expression, context));
} catch (error) {
// eslint-disable-next-line no-console
console.error('Expression evaluation error:', error);
return '';
}
}

if (!(identifier in values)) {
return '';
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return String(values[identifier]);
return String(values[identifier as keyof V]);
});
}
}
18 changes: 8 additions & 10 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2984,14 +2984,14 @@ __metadata:
ajv-errors: ~3.0.0
ajv-formats: ~2.1.1
es-aggregate-error: ^1.0.7
expr-eval-fork: ^3.0.1
jsonpath-plus: ^10.3.0
lodash: ~4.17.23
lodash.topath: ^4.5.2
minimatch: 3.1.2
nimma: 0.2.3
nock: ^13.5.4
pony-cause: ^1.1.1
simple-eval: 1.0.1
treeify: ^1.1.0
tslib: ^2.8.1
languageName: unknown
Expand Down Expand Up @@ -6957,6 +6957,13 @@ __metadata:
languageName: node
linkType: hard

"expr-eval-fork@npm:^3.0.1":
version: 3.0.1
resolution: "expr-eval-fork@npm:3.0.1"
checksum: 8841b2a521415cecd15cb860078ffe99678c59ab427a50c033f91426db12dff6450c72bb0a3f8bbe5c7125a83ccfab47209411b46cf0da5acbb4cf97c91eb362
languageName: node
linkType: hard

"ext@npm:^1.1.2":
version: 1.4.0
resolution: "ext@npm:1.4.0"
Expand Down Expand Up @@ -12856,15 +12863,6 @@ __metadata:
languageName: node
linkType: hard

"simple-eval@npm:1.0.1":
version: 1.0.1
resolution: "simple-eval@npm:1.0.1"
dependencies:
jsep: ^1.3.6
checksum: 280207cfe4538c500f6b41e4d88576cf250337b0042bec8f9f5cf025b3a70e07974e522edd01e69d378767dd73068765d4f46ad55db5c94943c8f3585bff95af
languageName: node
linkType: hard

"simple-get@npm:^4.0.0":
version: 4.0.1
resolution: "simple-get@npm:4.0.1"
Expand Down