From 043fdb54167d1f88c1ab5bde9aefa9bcddb6989c Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 12 Jun 2026 21:36:53 +0100 Subject: [PATCH] Resolve Proxy-backed addresses in configuration property traversal --- docs/guides/variables.md | 2 +- lib/configuration/variables/resolve.js | 19 ++++-- lib/plugins/print.js | 4 +- lib/utils/safe-object.js | 19 +++++- .../configuration/variables/resolve.test.js | 33 ++++++++++ test/unit/lib/plugins/print.test.js | 60 +++++++++++++++++++ test/unit/lib/utils/safe-object.test.js | 24 ++++++++ 7 files changed, 150 insertions(+), 11 deletions(-) diff --git a/docs/guides/variables.md b/docs/guides/variables.md index 88f3f4061..16fa17a3e 100644 --- a/docs/guides/variables.md +++ b/docs/guides/variables.md @@ -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 diff --git a/lib/configuration/variables/resolve.js b/lib/configuration/variables/resolve.js index d5a9bd508..bc4a53eba 100644 --- a/lib/configuration/variables/resolve.js +++ b/lib/configuration/variables/resolve.js @@ -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'); @@ -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 diff --git a/lib/plugins/print.js b/lib/plugins/print.js index acd901c84..cd3389098 100644 --- a/lib/plugins/print.js +++ b/lib/plugins/print.js @@ -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 { @@ -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'); diff --git a/lib/utils/safe-object.js b/lib/utils/safe-object.js index b9303d42a..491ec097b 100644 --- a/lib/utils/safe-object.js +++ b/lib/utils/safe-object.js @@ -45,14 +45,15 @@ 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]; } @@ -60,10 +61,22 @@ const getOwnByPath = (source, path) => { 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, diff --git a/test/unit/lib/configuration/variables/resolve.test.js b/test/unit/lib/configuration/variables/resolve.test.js index 459f5ca89..e11bf15bc 100644 --- a/test/unit/lib/configuration/variables/resolve.test.js +++ b/test/unit/lib/configuration/variables/resolve.test.js @@ -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:}', @@ -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'); @@ -528,6 +560,7 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => { 'invalidResultNonJsonCircular', 'invalidResultValue', 'nullWithCustomErrorMessage', + 'proxyTrapErrored', `infiniteResolutionRecursion${'\0nest'.repeat(10)}`, ]); }); diff --git a/test/unit/lib/plugins/print.test.js b/test/unit/lib/plugins/print.test.js index df9cc0f76..6abb963f5 100644 --- a/test/unit/lib/plugins/print.test.js +++ b/test/unit/lib/plugins/print.test.js @@ -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'); + } + }); }); diff --git a/test/unit/lib/utils/safe-object.test.js b/test/unit/lib/utils/safe-object.test.js index 96c38dc53..c99c2c1e5 100644 --- a/test/unit/lib/utils/safe-object.test.js +++ b/test/unit/lib/utils/safe-object.test.js @@ -5,6 +5,7 @@ const { expect } = require('chai'); const { createRegistry, getOwnByPath, + getReachableByPath, hasOwn, safeSet, safeShallowAssign, @@ -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); + }); });