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 docs/guides/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ functions:
- schedule: ${file(./scheduleConfig.js):rate} # Reference a specific module
```

Address resolution follows the value's own properties. If a JS file exports (or its resolver function returns) a `Proxy`-backed object, address segments are resolved through the proxy's `get` handler instead. The keys `__proto__`, `prototype` and `constructor` are only followed when they are own properties of the value, so an address can never traverse into prototype internals. This proxy-aware resolution applies to `file(...)` addresses only — when the same value is reached another way (for example via a `${self:...}` reference into a property that was populated from a `${file(...)}` variable, or via `serverless print --path`), only own properties are followed.
Address resolution follows the value's own properties. If a JS file exports (or its resolver function returns) a `Proxy`-backed object, address segments are resolved through the proxy's `get` handler instead. The keys `__proto__`, `prototype` and `constructor` are only followed when they are own properties of the value, so an address can never traverse into prototype internals. The same rules apply when such a value is reached another way a `${self:...}` reference into a property that was populated from a `${file(...)}` variable, and `serverless print --path`, also follow a Proxy's `get` handler, with the same restriction on `__proto__`, `prototype` and `constructor`.

### Exporting a function

Expand Down
19 changes: 14 additions & 5 deletions lib/configuration/variables/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const path = require('path');
const util = require('util');
const ServerlessError = require('../../serverless-error');
const { methodDescriptor } = require('../../utils/property-descriptors');
const { hasOwn, safeSet } = require('../../utils/safe-object');
const { canFollowProperty, hasOwn, safeSet } = require('../../utils/safe-object');
const humanizePropertyPath = require('./humanize-property-path-keys');
const parse = require('./parse');
const { parseEntries } = require('./resolve-meta');
Expand Down Expand Up @@ -437,11 +437,20 @@ class VariablesResolver {
if (depValueMeta.error) throw depValueMeta.error;
throw new ServerlessError('Cannot resolve variable', 'MISSING_VARIABLE_DEPENDENCY');
}
if (!hasOwn(value, key)) {
value = undefined;
break;
try {
if (!canFollowProperty(value, key)) {
value = undefined;
break;
}
value = value[key];
} catch (error) {
throw new ServerlessError(
`Cannot resolve "${humanizePropertyPath(
dependencyPropertyPathKeys
)}": Property access errored with: ${error && error.stack ? error.stack : error}`,
'CONFIGURATION_PROPERTY_ACCESS_ERROR'
);
}
value = value[key];
}
if (!isObject(value)) return value;
const depPropertyNestPath = dependencyPropertyPathKeys.length
Expand Down
4 changes: 2 additions & 2 deletions lib/plugins/print.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const jc = require('json-cycle');
const yaml = require('js-yaml');
const ServerlessError = require('../serverless-error');
const cliCommandsSchema = require('../cli/commands-schema');
const { getOwnByPath } = require('../utils/safe-object');
const { getReachableByPath } = require('../utils/safe-object');
const { writeText } = require('../utils/serverless-utils/log');

class Print {
Expand All @@ -31,7 +31,7 @@ class Print {
// dig into the object
if (this.options.path) {
const steps = this.options.path.split('.');
conf = getOwnByPath(conf, steps);
conf = getReachableByPath(conf, steps);

if (conf === undefined) {
throw new ServerlessError(`Path "${this.options.path}" not found`, 'INVALID_PATH_ARGUMENT');
Expand Down
19 changes: 16 additions & 3 deletions lib/utils/safe-object.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,38 @@ const safeShallowAssign = (target, ...sources) => {

const createRegistry = () => Object.create(null);

const getOwnByPath = (source, path) => {
const segments = Array.isArray(path)
const pathSegments = (path) =>
Array.isArray(path)
? path.map((segment) => String(segment))
: String(path).split('.').filter(Boolean);

const getOwnByPath = (source, path) => {
let current = source;

for (const segment of segments) {
for (const segment of pathSegments(path)) {
if (current == null || !hasOwn(current, segment)) return undefined;
current = current[segment];
}

return current;
};

const getReachableByPath = (source, path) => {
let current = source;

for (const segment of pathSegments(path)) {
if (current == null || !canFollowProperty(current, segment)) return undefined;
current = current[segment];
}

return current;
};

module.exports = {
canFollowProperty,
createRegistry,
getOwnByPath,
getReachableByPath,
hasOwn,
safeSet,
safeShallowAssign,
Expand Down
33 changes: 33 additions & 0 deletions test/unit/lib/configuration/variables/resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => {
configuration.arraySourceLength = '${sourceProperty(arraySource, length)}';
configuration.inheritedConstructorName =
'${sourceProperty(arraySource, constructor, name), null}';
configuration.proxyObject = new Proxy(
{ own: 'own-value' },
{
get(target, key) {
if (key === 'virtual') return { nested: 'proxy-virtual' };
if (key === 'boom') throw new Error('Proxy get trap crashed');
return target[key];
},
}
);
configuration.proxyVirtual = '${sourceProperty(proxyObject, virtual, nested)}';
configuration.proxyOwn = '${sourceProperty(proxyObject, own)}';
configuration.proxyMissingFallback = "${sourceProperty(proxyObject, missing), 'fallback'}";
configuration.proxyConstructor = '${sourceProperty(proxyObject, constructor, name), null}';
configuration.proxyTrapErrored = '${sourceProperty(proxyObject, boom)}';
configuration.resolvesUnsafeOwnProtoObject = '${sourceResultVariables(protoObject)}';
Object.defineProperty(configuration, '__proto__', {
value: '${sourceDirect:}',
Expand Down Expand Up @@ -323,6 +338,23 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => {
expect(configuration.inheritedConstructorName).to.equal(null);
});

it('should resolve Proxy-backed virtual properties across dependency paths', () => {
expect(configuration.proxyVirtual).to.equal('proxy-virtual');
expect(configuration.proxyOwn).to.equal('own-value');
expect(configuration.proxyMissingFallback).to.equal('fallback');
});

it('should not traverse unsafe keys on Proxy-backed dependency paths', () => {
expect(configuration.proxyConstructor).to.equal(null);
});

it('should mark with error a Proxy "get" trap that crashes during dependency resolution', () => {
const valueMeta = variablesMeta.get('proxyTrapErrored');
expect(valueMeta).to.not.have.property('variables');
expect(valueMeta.error.code).to.equal('VARIABLE_RESOLUTION_ERROR');
expect(valueMeta.error.message).to.include('Property access errored with');
});

it('should resolve variables in resolved strings which are subject to concatenation', () => {
expect(configuration.resolveDeepVariablesConcat).to.equal('234foo234');
expect(configuration.resolveDeepVariablesConcatInParam).to.equal('432oof432');
Expand Down Expand Up @@ -528,6 +560,7 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => {
'invalidResultNonJsonCircular',
'invalidResultValue',
'nullWithCustomErrorMessage',
'proxyTrapErrored',
`infiniteResolutionRecursion${'\0nest'.repeat(10)}`,
]);
});
Expand Down
60 changes: 60 additions & 0 deletions test/unit/lib/plugins/print.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,64 @@ describe('test/unit/lib/plugins/print.test.js', () => {
expect(error.code).to.equal('INVALID_PATH_ARGUMENT');
}
});

it('resolves Proxy-backed virtual path segments', async () => {
const writeText = sinon.spy();
const Print = proxyquire('../../../../lib/plugins/print', {
'../utils/serverless-utils/log': { writeText },
});
const serverless = {
configurationInput: {
custom: {
proxy: new Proxy(
{ own: 'own-value' },
{
get(target, key) {
if (key === 'virtual') return { nested: 'proxy-virtual' };
return target[key];
},
}
),
},
},
};

await new Print(serverless, { path: 'custom.proxy.virtual.nested', format: 'text' }).print();
expect(writeText.calledWithExactly('proxy-virtual')).to.equal(true);

writeText.resetHistory();
await new Print(serverless, { path: 'custom.proxy.own', format: 'text' }).print();
expect(writeText.calledWithExactly('own-value')).to.equal(true);
});

it('does not resolve unsafe path segments on Proxy-backed values', async () => {
const writeText = sinon.spy();
const Print = proxyquire('../../../../lib/plugins/print', {
'../utils/serverless-utils/log': { writeText },
});
const serverless = {
configurationInput: {
custom: {
proxy: new Proxy(
{},
{
get(target, key) {
return target[key];
},
}
),
},
},
};

try {
await new Print(serverless, {
path: 'custom.proxy.constructor.name',
format: 'text',
}).print();
throw new Error('Expected print() to reject');
} catch (error) {
expect(error.code).to.equal('INVALID_PATH_ARGUMENT');
}
});
});
24 changes: 24 additions & 0 deletions test/unit/lib/utils/safe-object.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { expect } = require('chai');
const {
createRegistry,
getOwnByPath,
getReachableByPath,
hasOwn,
safeSet,
safeShallowAssign,
Expand Down Expand Up @@ -158,4 +159,27 @@ describe('safe-object', () => {
expect(getOwnByPath({ nested: null }, 'nested.value')).to.equal(undefined);
expect(getOwnByPath({ nested: 5 }, 'nested.value')).to.equal(undefined);
});

it('follows Proxy virtual properties for safe keys when traversing reachable paths', () => {
const source = {
proxy: new Proxy(
{ own: 'own-value' },
{
get(target, key) {
if (key === 'virtual') return { nested: 'proxy-virtual' };
return target[key];
},
}
),
};

expect(getReachableByPath(source, 'proxy.virtual.nested')).to.equal('proxy-virtual');
expect(getReachableByPath(source, ['proxy', 'own'])).to.equal('own-value');
expect(getReachableByPath(source, 'proxy.missing')).to.equal(undefined);
expect(getReachableByPath(source, ['proxy', 'constructor', 'name'])).to.equal(undefined);
expect(getReachableByPath({ nested: {} }, ['nested', 'constructor', 'name'])).to.equal(
undefined
);
expect(getReachableByPath({ nested: null }, 'nested.value')).to.equal(undefined);
});
});
Loading