From 6c85a092b592a80122a94564b64ddeb896c53c54 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Thu, 30 Jul 2026 14:30:45 +0530 Subject: [PATCH 1/5] fix: address path traversal vulnerability in config handlers --- .../directory/handlers/actionModules.ts | 35 +++--- src/context/directory/handlers/actions.ts | 36 +++--- src/context/directory/handlers/databases.ts | 34 ++++-- src/context/directory/handlers/hooks.ts | 23 +++- src/context/directory/handlers/rules.ts | 23 +++- src/context/directory/index.ts | 25 ++-- src/context/yaml/index.ts | 25 ++-- src/types.ts | 1 + test/context/directory/actions.test.js | 108 +++++++++++++++++- test/context/directory/hooks.test.js | 27 +++++ test/context/directory/rules.test.js | 26 +++++ 11 files changed, 295 insertions(+), 68 deletions(-) diff --git a/src/context/directory/handlers/actionModules.ts b/src/context/directory/handlers/actionModules.ts index eb90d69a1..ff7bcc1ae 100644 --- a/src/context/directory/handlers/actionModules.ts +++ b/src/context/directory/handlers/actionModules.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs-extra'; -import { constants } from '../../../tools'; +import { constants, loadFileAndReplaceKeywords } from '../../../tools'; import { getFiles, existsMustBeDir, loadJSON, sanitize, dumpJSON } from '../../../utils'; import log from '../../../logger'; @@ -24,22 +24,27 @@ function parse(context: DirectoryContext): ParsedActionModules { disableKeywordReplacement: context.disableKeywordReplacement, }), }; - const moduleFolder = path.join(constants.ACTION_MODULES_DIRECTORY, `${module.name}`); - if (module.code) { - // The `module.code` can be a file path. It needs to be loaded. - // It can be a relative path, so we need to handle both cases. - const unixPath = module.code.replace(/[\\/]+/g, '/').replace(/^([a-zA-Z]+:|\.\/)/, ''); - if (fs.existsSync(unixPath)) { - log.warn( - `Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` + - `Please update your configuration to use paths relative to the config directory. ` + - `Current absolute path used: ["${module.code}"]` - ); - module.code = context.loadFile(unixPath, moduleFolder); - } else { - module.code = context.loadFile(path.join(context.filePath, module.code), moduleFolder); + const normalizedCode = module.code.replace(/\\/g, '/'); + const configRoot = path.resolve(context.filePath); + const resolvedPath = path.resolve(context.filePath, normalizedCode); + if (!resolvedPath.startsWith(configRoot + path.sep)) { + if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${module.code}"` + ); + } else { + log.warn( + `Path "${module.code}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } } + module.code = loadFileAndReplaceKeywords(resolvedPath, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }); } return module; diff --git a/src/context/directory/handlers/actions.ts b/src/context/directory/handlers/actions.ts index 25df66cec..3d917db30 100644 --- a/src/context/directory/handlers/actions.ts +++ b/src/context/directory/handlers/actions.ts @@ -1,7 +1,7 @@ /* eslint-disable consistent-return */ import path from 'path'; import fs from 'fs-extra'; -import { constants } from '../../../tools'; +import { constants, loadFileAndReplaceKeywords } from '../../../tools'; import { getFiles, existsMustBeDir, loadJSON, sanitize, dumpJSON } from '../../../utils'; import log from '../../../logger'; @@ -25,23 +25,27 @@ function parse(context: DirectoryContext): ParsedActions { disableKeywordReplacement: context.disableKeywordReplacement, }), }; - const actionFolder = path.join(constants.ACTIONS_DIRECTORY, `${action.name}`); - if (action.code) { - // Convert `action.code` path to Unix-style path by replacing backslashes and multiple slashes with a single forward slash, and remove leading drive letters or './'. - const unixPath = action.code.replace(/[\\/]+/g, '/').replace(/^([a-zA-Z]+:|\.\/)/, ''); - if (fs.existsSync(unixPath)) { - // If the Unix-style path exists, load the file from that path - log.warn( - `Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` + - `Please update your configuration to use paths relative to the config directory. ` + - `Current absolute path used: ["${action.code}"]` - ); - action.code = context.loadFile(unixPath, actionFolder); - } else { - // Otherwise, load the file from the context's file path - action.code = context.loadFile(path.join(context.filePath, action.code), actionFolder); + const normalizedCode = action.code.replace(/\\/g, '/'); + const configRoot = path.resolve(context.filePath); + const resolvedPath = path.resolve(context.filePath, normalizedCode); + if (!resolvedPath.startsWith(configRoot + path.sep)) { + if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${action.code}"` + ); + } else { + log.warn( + `Path "${action.code}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } } + action.code = loadFileAndReplaceKeywords(resolvedPath, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }); } return action; diff --git a/src/context/directory/handlers/databases.ts b/src/context/directory/handlers/databases.ts index 99ffbb1ee..94187e532 100644 --- a/src/context/directory/handlers/databases.ts +++ b/src/context/directory/handlers/databases.ts @@ -34,7 +34,8 @@ type DatabaseMetadata = { function getDatabase( folder: string, configRoot: string, - mappingOpts: { mappings: KeywordMappings; disableKeywordReplacement: boolean } + mappingOpts: { mappings: KeywordMappings; disableKeywordReplacement: boolean }, + allowExternalPaths?: boolean ): {} { const metaFile = path.join(folder, 'database.json'); @@ -70,13 +71,19 @@ function getDatabase( log.warn('Skipping invalid database configuration: ' + name); } else { const resolvedBase = path.resolve(configRoot); - const toLoad = path.resolve(folder, script); + const toLoad = path.resolve(folder, script.replace(/\\/g, '/')); if (!toLoad.startsWith(resolvedBase + path.sep)) { - log.warn( - `Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` + - `Please update your configuration to use paths relative to the config directory. ` + - `Current absolute path used: ["${script}"]` - ); + if (allowExternalPaths) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${script}"` + ); + } else { + log.warn( + `Path "${script}" resolves to "${toLoad}" which is outside the config directory "${resolvedBase}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } } database.options.customScripts[name] = loadFileAndReplaceKeywords(toLoad, mappingOpts); } @@ -97,10 +104,15 @@ function parse(context: DirectoryContext): ParsedDatabases { const databases = folders .map((f) => - getDatabase(f, context.filePath, { - mappings: context.mappings, - disableKeywordReplacement: context.disableKeywordReplacement, - }) + getDatabase( + f, + context.filePath, + { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }, + context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS + ) ) .filter((p) => Object.keys(p).length > 1); diff --git a/src/context/directory/handlers/hooks.ts b/src/context/directory/handlers/hooks.ts index 82e2db64b..e7a229d8e 100644 --- a/src/context/directory/handlers/hooks.ts +++ b/src/context/directory/handlers/hooks.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs-extra'; -import { constants } from '../../../tools'; +import { constants, loadFileAndReplaceKeywords } from '../../../tools'; import { getFiles, existsMustBeDir, dumpJSON, loadJSON, sanitize } from '../../../utils'; import log from '../../../logger'; @@ -24,7 +24,26 @@ function parse(context: DirectoryContext): ParsedHooks { }), }; if (hook.script) { - hook.script = context.loadFile(hook.script, constants.HOOKS_DIRECTORY); + const normalizedScript = hook.script.replace(/\\/g, '/'); + const configRoot = path.resolve(context.filePath); + const resolvedPath = path.resolve(context.filePath, constants.HOOKS_DIRECTORY, normalizedScript); + if (!resolvedPath.startsWith(configRoot + path.sep)) { + if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${hook.script}"` + ); + } else { + log.warn( + `Path "${hook.script}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } + } + hook.script = loadFileAndReplaceKeywords(resolvedPath, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }); } hook.name = hook.name.toLowerCase().replace(/\s/g, '-'); diff --git a/src/context/directory/handlers/rules.ts b/src/context/directory/handlers/rules.ts index 716b24507..b2b178738 100644 --- a/src/context/directory/handlers/rules.ts +++ b/src/context/directory/handlers/rules.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs-extra'; -import { constants } from '../../../tools'; +import { constants, loadFileAndReplaceKeywords } from '../../../tools'; import log from '../../../logger'; import { getFiles, existsMustBeDir, dumpJSON, loadJSON, sanitize } from '../../../utils'; @@ -25,7 +25,26 @@ function parse(context: DirectoryContext): ParsedRules { }), }; if (rule.script) { - rule.script = context.loadFile(rule.script, constants.RULES_DIRECTORY); + const normalizedScript = rule.script.replace(/\\/g, '/'); + const configRoot = path.resolve(context.filePath); + const resolvedPath = path.resolve(context.filePath, constants.RULES_DIRECTORY, normalizedScript); + if (!resolvedPath.startsWith(configRoot + path.sep)) { + if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${rule.script}"` + ); + } else { + log.warn( + `Path "${rule.script}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } + } + rule.script = loadFileAndReplaceKeywords(resolvedPath, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }); } return rule; }); diff --git a/src/context/directory/index.ts b/src/context/directory/index.ts index bc5d211c7..dbb2ca367 100644 --- a/src/context/directory/index.ts +++ b/src/context/directory/index.ts @@ -47,16 +47,21 @@ export default class DirectoryContext { } loadFile(f: string, folder: string) { - const basePath = path.join(this.filePath, folder); - let toLoad = path.join(basePath, f); - if (!isFile(toLoad)) { - // try load not relative to yaml file - toLoad = f; - log.warn( - `Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` + - `Please update your configuration to use paths relative to the config directory. ` + - `Current absolute path used: ["${f}"]` - ); + const configRoot = path.resolve(this.filePath); + const basePath = path.resolve(this.filePath, folder); + const toLoad = path.resolve(basePath, f.replace(/\\/g, '/')); + if (!toLoad.startsWith(configRoot + path.sep)) { + if (this.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${f}"` + ); + } else { + log.warn( + `Path "${f}" resolves to "${toLoad}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } } return loadFileAndReplaceKeywords(toLoad, { mappings: this.mappings, diff --git a/src/context/yaml/index.ts b/src/context/yaml/index.ts index 5a965cd9f..2f44e4ff8 100644 --- a/src/context/yaml/index.ts +++ b/src/context/yaml/index.ts @@ -58,17 +58,22 @@ export default class YAMLContext { } loadFile(f) { - let toLoad = path.join(this.basePath, f); - if (!isFile(toLoad)) { - // try load not relative to yaml file - toLoad = f; - log.warn( - `Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` + - `Please update your configuration to use paths relative to the config directory. ` + - `Current absolute path used: ["${f}"]` - ); + const configRoot = path.resolve(this.basePath); + const toLoad = path.resolve(this.basePath, f.replace(/\\/g, '/')); + if (!toLoad.startsWith(configRoot + path.sep)) { + if (this.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { + log.debug( + `Loading file outside config directory (AUTH0_ALLOW_EXTERNAL_CODE_PATHS enabled): "${f}"` + ); + } else { + log.warn( + `Path "${f}" resolves to "${toLoad}" which is outside the config directory "${configRoot}". ` + + `This will be blocked as an error in the next major release. ` + + `Move the file inside your config directory or set AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true to allow it.` + ); + } } - return loadFileAndReplaceKeywords(path.resolve(toLoad), { + return loadFileAndReplaceKeywords(toLoad, { mappings: this.mappings, disableKeywordReplacement: this.disableKeywordReplacement, }); diff --git a/src/types.ts b/src/types.ts index e390ff1e4..e9c5ca898 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,7 @@ export type Config = { AUTH0_EXCLUDED_RESOURCE_SERVERS?: string[]; AUTH0_EXCLUDED_DEFAULTS?: string[]; AUTH0_EXPERIMENTAL_EA: boolean; + AUTH0_ALLOW_EXTERNAL_CODE_PATHS?: boolean; }; // TODO: replace with a more accurate representation of the Config type export type Asset = { [key: string]: any }; diff --git a/test/context/directory/actions.test.js b/test/context/directory/actions.test.js index 976d693af..b83744238 100644 --- a/test/context/directory/actions.test.js +++ b/test/context/directory/actions.test.js @@ -1,8 +1,10 @@ import path from 'path'; import fs from 'fs-extra'; +import sinon from 'sinon'; import { expect } from 'chai'; import { constants } from '../../../src/tools'; +import log from '../../../src/logger'; import Context from '../../../src/context/directory'; import handler from '../../../src/context/directory/handlers/actions'; @@ -15,7 +17,7 @@ const actionFiles = { '/** @type {PostLoginAction} */ module.exports = async (event, context) => { console.log(@@replace@@); return {}; };', 'action-one.json': `{ "name": "action-one", - "code": "./local/testData/directory/test1/actions/code.js", + "code": "./actions/code.js", "runtime": "node12", "dependencies": [ { @@ -42,7 +44,7 @@ const actionFilesWin32 = { '/** @type {PostLoginAction} */ module.exports = async (event, context) => { console.log(@@replace@@); return {}; };', 'action-one.json': `{ "name": "action-one", - "code": "local\\\\testData\\\\directory\\\\test1\\\\actions\\\\code.js", + "code": "actions\\\\code.js", "runtime": "node12", "dependencies": [ { @@ -351,6 +353,108 @@ describe('#directory context actions', () => { expect(context.assets.actions).to.deep.equal(target); }); + it('should not warn when action code path is relative and inside the config root', async () => { + const repoDir = path.join(testDataDir, 'directory', 'test-no-warn'); + const files = { + [constants.ACTIONS_DIRECTORY]: { + 'code.js': 'module.exports = () => {};', + 'action-one.json': `{ + "name": "action-one", + "code": "./actions/code.js", + "runtime": "node18", + "dependencies": [], + "secrets": [], + "status": "built", + "supported_triggers": [{ "id": "post-login", "version": "v3" }], + "deployed": true + }`, + }, + }; + createDir(repoDir, files); + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + const warnSpy = sinon.spy(log, 'warn'); + try { + await context.loadAssetsFromLocal(); + const deprecationWarned = warnSpy.args.some(([msg]) => + msg.includes('will be blocked as an error') + ); + expect(deprecationWarned).to.be.false; + } finally { + warnSpy.restore(); + } + }); + + it('should warn when action code path resolves outside the config root', async () => { + const repoDir = path.join(testDataDir, 'directory', 'test-traversal-warn'); + const outsideFile = path.join(testDataDir, 'directory', 'outside-action-code.js'); + fs.ensureDirSync(path.join(repoDir, constants.ACTIONS_DIRECTORY)); + fs.writeFileSync(outsideFile, 'module.exports = () => {};'); + const files = { + [constants.ACTIONS_DIRECTORY]: { + 'action-one.json': `{ + "name": "action-one", + "code": "../outside-action-code.js", + "runtime": "node18", + "dependencies": [], + "secrets": [], + "status": "built", + "supported_triggers": [{ "id": "post-login", "version": "v3" }], + "deployed": true + }`, + }, + }; + createDir(repoDir, files); + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + const warnSpy = sinon.spy(log, 'warn'); + try { + await context.loadAssetsFromLocal(); + const deprecationWarned = warnSpy.args.some(([msg]) => + msg.includes('will be blocked as an error') + ); + expect(deprecationWarned).to.be.true; + } finally { + warnSpy.restore(); + fs.removeSync(outsideFile); + } + }); + + it('should not warn when AUTH0_ALLOW_EXTERNAL_CODE_PATHS is true and path is outside config root', async () => { + const repoDir = path.join(testDataDir, 'directory', 'test-escape-hatch'); + const outsideFile = path.join(testDataDir, 'directory', 'escape-hatch-code.js'); + fs.ensureDirSync(path.join(repoDir, constants.ACTIONS_DIRECTORY)); + fs.writeFileSync(outsideFile, 'module.exports = () => {};'); + const files = { + [constants.ACTIONS_DIRECTORY]: { + 'action-one.json': `{ + "name": "action-one", + "code": "../escape-hatch-code.js", + "runtime": "node18", + "dependencies": [], + "secrets": [], + "status": "built", + "supported_triggers": [{ "id": "post-login", "version": "v3" }], + "deployed": true + }`, + }, + }; + createDir(repoDir, files); + const config = { AUTH0_INPUT_FILE: repoDir, AUTH0_ALLOW_EXTERNAL_CODE_PATHS: true }; + const context = new Context(config, mockMgmtClient()); + const warnSpy = sinon.spy(log, 'warn'); + try { + await context.loadAssetsFromLocal(); + const deprecationWarned = warnSpy.args.some(([msg]) => + msg.includes('will be blocked as an error') + ); + expect(deprecationWarned).to.be.false; + } finally { + warnSpy.restore(); + fs.removeSync(outsideFile); + } + }); + it('should dump actions with modules', async () => { const actionName = 'action-with-modules'; const dir = path.join(testDataDir, 'directory', 'test-action-modules'); diff --git a/test/context/directory/hooks.test.js b/test/context/directory/hooks.test.js index 92c98b3de..d835a6279 100644 --- a/test/context/directory/hooks.test.js +++ b/test/context/directory/hooks.test.js @@ -1,7 +1,9 @@ import path from 'path'; import fs from 'fs-extra'; +import sinon from 'sinon'; import { expect } from 'chai'; import { constants } from '../../../src/tools'; +import log from '../../../src/logger'; import Context from '../../../src/context/directory'; import handler from '../../../src/context/directory/handlers/hooks'; @@ -75,6 +77,31 @@ describe('#directory context hooks', () => { .and.have.property('message', errorMessage); }); + it('should warn when hook script path resolves outside the config root', async () => { + const repoDir = path.join(testDataDir, 'directory', 'hooks-traversal-warn'); + const outsideFile = path.join(testDataDir, 'directory', 'outside-hook.js'); + fs.ensureDirSync(path.join(repoDir, constants.HOOKS_DIRECTORY)); + fs.writeFileSync(outsideFile, 'function outside() {}'); + const traversalHooks = { + 'some-hook.json': + '{ "name": "Some Hook", "enabled": true, "script": "../../outside-hook.js", "triggerId": "credentials-exchange" }', + }; + createDir(repoDir, { [constants.HOOKS_DIRECTORY]: traversalHooks }); + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + const warnSpy = sinon.spy(log, 'warn'); + try { + await context.loadAssetsFromLocal(); + const deprecationWarned = warnSpy.args.some(([msg]) => + msg.includes('will be blocked as an error') + ); + expect(deprecationWarned).to.be.true; + } finally { + warnSpy.restore(); + fs.removeSync(outsideFile); + } + }); + it('should dump hooks', async () => { const dir = path.join(testDataDir, 'yaml', 'hooksDump'); cleanThenMkdir(dir); diff --git a/test/context/directory/rules.test.js b/test/context/directory/rules.test.js index 372bec565..9508a8f31 100644 --- a/test/context/directory/rules.test.js +++ b/test/context/directory/rules.test.js @@ -1,7 +1,9 @@ import path from 'path'; import fs from 'fs-extra'; +import sinon from 'sinon'; import { expect } from 'chai'; import { constants } from '../../../src/tools'; +import log from '../../../src/logger'; import Context from '../../../src/context/directory'; import handler from '../../../src/context/directory/handlers/rules'; @@ -128,6 +130,30 @@ describe('#directory context rules', () => { ); }); + it('should warn when rule script path resolves outside the config root', async () => { + const repoDir = path.join(testDataDir, 'directory', 'rules-traversal-warn'); + const outsideFile = path.join(testDataDir, 'directory', 'outside-rule.js'); + fs.ensureDirSync(path.join(repoDir, constants.RULES_DIRECTORY)); + fs.writeFileSync(outsideFile, 'function outside() {}'); + const traversalRules = { + 'somerule.json': '{ "name": "somerule", "enabled": true, "script": "../../outside-rule.js" }', + }; + createDir(repoDir, { [constants.RULES_DIRECTORY]: traversalRules }); + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + const warnSpy = sinon.spy(log, 'warn'); + try { + await context.loadAssetsFromLocal(); + const deprecationWarned = warnSpy.args.some(([msg]) => + msg.includes('will be blocked as an error') + ); + expect(deprecationWarned).to.be.true; + } finally { + warnSpy.restore(); + fs.removeSync(outsideFile); + } + }); + it('should not dump excluded rules', async () => { const dir = path.join(testDataDir, 'directory', 'rulesDumpExclude'); cleanThenMkdir(dir); From fce448ee0f38d6af3082987ac14d20020b381bad Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Thu, 30 Jul 2026 16:43:01 +0530 Subject: [PATCH 2/5] format fix --- src/context/directory/handlers/hooks.ts | 6 +++++- src/context/directory/handlers/rules.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/context/directory/handlers/hooks.ts b/src/context/directory/handlers/hooks.ts index e7a229d8e..cb5bcf92a 100644 --- a/src/context/directory/handlers/hooks.ts +++ b/src/context/directory/handlers/hooks.ts @@ -26,7 +26,11 @@ function parse(context: DirectoryContext): ParsedHooks { if (hook.script) { const normalizedScript = hook.script.replace(/\\/g, '/'); const configRoot = path.resolve(context.filePath); - const resolvedPath = path.resolve(context.filePath, constants.HOOKS_DIRECTORY, normalizedScript); + const resolvedPath = path.resolve( + context.filePath, + constants.HOOKS_DIRECTORY, + normalizedScript + ); if (!resolvedPath.startsWith(configRoot + path.sep)) { if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { log.debug( diff --git a/src/context/directory/handlers/rules.ts b/src/context/directory/handlers/rules.ts index b2b178738..a2f6483dc 100644 --- a/src/context/directory/handlers/rules.ts +++ b/src/context/directory/handlers/rules.ts @@ -27,7 +27,11 @@ function parse(context: DirectoryContext): ParsedRules { if (rule.script) { const normalizedScript = rule.script.replace(/\\/g, '/'); const configRoot = path.resolve(context.filePath); - const resolvedPath = path.resolve(context.filePath, constants.RULES_DIRECTORY, normalizedScript); + const resolvedPath = path.resolve( + context.filePath, + constants.RULES_DIRECTORY, + normalizedScript + ); if (!resolvedPath.startsWith(configRoot + path.sep)) { if (context.config.AUTH0_ALLOW_EXTERNAL_CODE_PATHS) { log.debug( From 91946de7eda44060a087f4a416ac7d9e6cb36368 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Tue, 4 Aug 2026 21:31:02 +0530 Subject: [PATCH 3/5] docs: add AUTH0_ALLOW_EXTERNAL_CODE_PATHS configuration reference --- docs/configuring-the-deploy-cli.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/configuring-the-deploy-cli.md b/docs/configuring-the-deploy-cli.md index 650c84d00..72cebeaf9 100644 --- a/docs/configuring-the-deploy-cli.md +++ b/docs/configuring-the-deploy-cli.md @@ -185,6 +185,35 @@ Boolean. When enabled, exports actual secret values (e.g. connection `client_sec > **Warning:** Enabling this option will write real credentials to exported files. Use with caution in shared or version-controlled environments. +### `AUTH0_ALLOW_EXTERNAL_CODE_PATHS` + +Boolean. When enabled, allows code files referenced in resource configurations (such as action code, hook scripts, rule scripts, and database custom scripts) to be loaded from paths outside the config root directory. Default: `false`. + +**Background:** The Deploy CLI enforces that all file path references in resource configurations must resolve within the config root directory as a security measure to prevent unintended file access. When a path resolves outside the config root, a deprecation warning is emitted and the file is still loaded. **In the next major release this will become a hard error.** If you see this warning, move the referenced file inside your config directory before upgrading. + +**Monorepo use case:** In some project structures — particularly monorepos — action code or scripts are intentionally stored in a shared directory that sits above the config root. For example: + +``` +my-monorepo/ +├── shared/ +│ └── action-code.js # shared code, lives outside the config root +└── auth0-config/ # AUTH0_INPUT_FILE points here + └── actions/ + └── my-action.json # references "../../shared/action-code.js" +``` + +In this case the path check would emit a deprecation warning (and will block deployment in the next major release). Setting `AUTH0_ALLOW_EXTERNAL_CODE_PATHS=true` explicitly opts out of the enforcement, suppressing the warning and allowing these cross-directory references to load successfully. + +> **Security notice:** This flag bypasses a security boundary introduced to prevent path traversal. Only enable it if your project structure genuinely requires loading files from outside the config directory, and ensure you trust all config files being processed. **Do not enable this flag in environments where config files are sourced from untrusted or unreviewed input.** + +#### Example + +```json +{ + "AUTH0_ALLOW_EXTERNAL_CODE_PATHS": true +} +``` + ### `EXCLUDED_PROPS` Provides ability to exclude any unwanted properties from management. From 0e468b16697b6b5057c1bcd3525f41bcb64ca9ce Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Wed, 5 Aug 2026 01:00:07 +0530 Subject: [PATCH 4/5] fix format --- src/context/directory/index.ts | 2 +- src/context/yaml/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context/directory/index.ts b/src/context/directory/index.ts index dbb2ca367..4a995ab9f 100644 --- a/src/context/directory/index.ts +++ b/src/context/directory/index.ts @@ -6,7 +6,7 @@ import pagedClient from '../../tools/auth0/client'; import cleanAssets from '../../readonly'; import log from '../../logger'; import handlers, { DirectoryHandler } from './handlers'; -import { isDirectory, isFile, stripIdentifiers, toConfigFn } from '../../utils'; +import { isDirectory, stripIdentifiers, toConfigFn } from '../../utils'; import { Assets, Auth0APIClient, Config, AssetTypes } from '../../types'; import { filterOnlyIncludedResourceTypes } from '..'; import { preserveKeywords } from '../../keywordPreservation'; diff --git a/src/context/yaml/index.ts b/src/context/yaml/index.ts index 2f44e4ff8..26b57bdd4 100644 --- a/src/context/yaml/index.ts +++ b/src/context/yaml/index.ts @@ -11,7 +11,7 @@ import { import pagedClient from '../../tools/auth0/client'; import log from '../../logger'; -import { isFile, toConfigFn, stripIdentifiers, formatResults, recordsSorter } from '../../utils'; +import { toConfigFn, stripIdentifiers, formatResults, recordsSorter } from '../../utils'; import handlers, { YAMLHandler } from './handlers'; import cleanAssets from '../../readonly'; import { Assets, Config, Auth0APIClient, AssetTypes, KeywordMappings } from '../../types'; From 86355e51a754de4747f28ba0d6313cf91565c3ad Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Wed, 5 Aug 2026 02:47:36 +0530 Subject: [PATCH 5/5] fix: restore existing warn spy before creating new one in directory handler tests --- test/context/directory/actions.test.js | 3 +++ test/context/directory/hooks.test.js | 1 + test/context/directory/rules.test.js | 1 + 3 files changed, 5 insertions(+) diff --git a/test/context/directory/actions.test.js b/test/context/directory/actions.test.js index b83744238..ed7a05521 100644 --- a/test/context/directory/actions.test.js +++ b/test/context/directory/actions.test.js @@ -373,6 +373,7 @@ describe('#directory context actions', () => { createDir(repoDir, files); const config = { AUTH0_INPUT_FILE: repoDir }; const context = new Context(config, mockMgmtClient()); + if (log.warn.restore) log.warn.restore(); const warnSpy = sinon.spy(log, 'warn'); try { await context.loadAssetsFromLocal(); @@ -407,6 +408,7 @@ describe('#directory context actions', () => { createDir(repoDir, files); const config = { AUTH0_INPUT_FILE: repoDir }; const context = new Context(config, mockMgmtClient()); + if (log.warn.restore) log.warn.restore(); const warnSpy = sinon.spy(log, 'warn'); try { await context.loadAssetsFromLocal(); @@ -442,6 +444,7 @@ describe('#directory context actions', () => { createDir(repoDir, files); const config = { AUTH0_INPUT_FILE: repoDir, AUTH0_ALLOW_EXTERNAL_CODE_PATHS: true }; const context = new Context(config, mockMgmtClient()); + if (log.warn.restore) log.warn.restore(); const warnSpy = sinon.spy(log, 'warn'); try { await context.loadAssetsFromLocal(); diff --git a/test/context/directory/hooks.test.js b/test/context/directory/hooks.test.js index d835a6279..a4a9676ac 100644 --- a/test/context/directory/hooks.test.js +++ b/test/context/directory/hooks.test.js @@ -89,6 +89,7 @@ describe('#directory context hooks', () => { createDir(repoDir, { [constants.HOOKS_DIRECTORY]: traversalHooks }); const config = { AUTH0_INPUT_FILE: repoDir }; const context = new Context(config, mockMgmtClient()); + if (log.warn.restore) log.warn.restore(); const warnSpy = sinon.spy(log, 'warn'); try { await context.loadAssetsFromLocal(); diff --git a/test/context/directory/rules.test.js b/test/context/directory/rules.test.js index 9508a8f31..b55dfeedf 100644 --- a/test/context/directory/rules.test.js +++ b/test/context/directory/rules.test.js @@ -141,6 +141,7 @@ describe('#directory context rules', () => { createDir(repoDir, { [constants.RULES_DIRECTORY]: traversalRules }); const config = { AUTH0_INPUT_FILE: repoDir }; const context = new Context(config, mockMgmtClient()); + if (log.warn.restore) log.warn.restore(); const warnSpy = sinon.spy(log, 'warn'); try { await context.loadAssetsFromLocal();