diff --git a/docs/guides/variables.md b/docs/guides/variables.md index dbb8f199d..295c34303 100644 --- a/docs/guides/variables.md +++ b/docs/guides/variables.md @@ -497,6 +497,8 @@ 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. + ### Exporting a function _Note: the method described below works by default in Serverless v3, but it requires the `variablesResolutionMode: 20210326` option in v2. In v3, `variablesResolutionMode` is obsolete; the old `20210219` mode is scheduled to be rejected in osls v4.0.0, and the property itself is scheduled for removal in osls v5.0.0._ diff --git a/lib/configuration/variables/sources/file.js b/lib/configuration/variables/sources/file.js index a7e0459e0..f9ecc6fe1 100644 --- a/lib/configuration/variables/sources/file.js +++ b/lib/configuration/variables/sources/file.js @@ -4,10 +4,12 @@ const ensureString = require('type/string/ensure'); const isPlainFunction = require('type/plain-function/is'); const path = require('path'); const fsp = require('fs').promises; +const { isProxy } = require('util').types; const yaml = require('js-yaml'); const cloudformationSchema = require('../../../utils/serverless-utils/cloudformation-schema'); const ServerlessError = require('../../../serverless-error'); const { hasOwn } = require('../../../utils/safe-object'); +const { isUnsafePropertyKey } = require('../../../utils/object-path'); const readFile = async (filePath, serviceDir) => { try { @@ -150,7 +152,13 @@ module.exports = { let result = content; for (const propertyKey of propertyKeys) { if (typeof result === 'string') result = await resolveVariablesInString(result); - if (result == null || !hasOwn(result, propertyKey)) return { value: null }; + if (result == null) return { value: null }; + if (!hasOwn(result, propertyKey)) { + // `hasOwnProperty` does not consult Proxy "has"/"get" traps, so virtual + // properties of Proxy-backed JS file results must be resolved through + // property access. Prototype internals stay blocked unless claimed as own. + if (!isProxy(result) || isUnsafePropertyKey(propertyKey)) return { value: null }; + } result = result[propertyKey]; if (result == null) return { value: null }; if (!isResolvedByFunction) { diff --git a/test/unit/lib/configuration/variables/sources/file.test.js b/test/unit/lib/configuration/variables/sources/file.test.js index 439f9a3d3..f2837ecdf 100644 --- a/test/unit/lib/configuration/variables/sources/file.test.js +++ b/test/unit/lib/configuration/variables/sources/file.test.js @@ -33,6 +33,10 @@ describe('test/unit/lib/configuration/variables/sources/file.test.js', () => { arrayLengthAddress: '${file(file-array.json):items.length}', unsafeOwnProtoAddress: '${file(file-unsafe-keys.json):__proto__.value}', inheritedConstructorAddress: '${file(file-unsafe-keys.json):constructor.name, null}', + proxyVirtualAddress: '${file(file-proxy.js):proxy.virtual.nested}', + proxyOwnAddress: '${file(file-proxy.js):proxy.own}', + proxyMissingAddress: '${file(file-proxy.js):proxy.missing, null}', + proxyInheritedConstructorAddress: '${file(file-proxy.js):proxy.constructor.name, null}', nonExistingYaml: '${file(not-existing.yaml), null}', nonExistingJson: '${file(not-existing.json), null}', nonExistingJs: '${file(not-existing.js), null}', @@ -130,6 +134,16 @@ describe('test/unit/lib/configuration/variables/sources/file.test.js', () => { expect(configuration.inheritedConstructorAddress).to.equal(null); }); + it('should resolve Proxy-backed virtual properties across file address resolution', () => { + expect(configuration.proxyVirtualAddress).to.equal('proxy-virtual'); + expect(configuration.proxyOwnAddress).to.equal('own-value'); + expect(configuration.proxyMissingAddress).to.equal(null); + }); + + it('should not traverse unsafe keys on Proxy-backed file results', () => { + expect(configuration.proxyInheritedConstructorAddress).to.equal(null); + }); + it('should unconditionally split "address" property keys by "."', () => expect(configuration.ambiguousAddress).to.equal('object')); diff --git a/test/unit/lib/configuration/variables/sources/fixture/file-proxy.js b/test/unit/lib/configuration/variables/sources/fixture/file-proxy.js new file mode 100644 index 000000000..9a11c60d2 --- /dev/null +++ b/test/unit/lib/configuration/variables/sources/fixture/file-proxy.js @@ -0,0 +1,15 @@ +'use strict'; + +const virtualProperties = { virtual: { nested: 'proxy-virtual' } }; + +module.exports = { + proxy: new Proxy( + { own: 'own-value' }, + { + get(target, key) { + if (key in virtualProperties) return virtualProperties[key]; + return target[key]; + }, + } + ), +};