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. 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..cb5bcf92a 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,30 @@ 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..a2f6483dc 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,30 @@ 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..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'; @@ -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..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'; @@ -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..ed7a05521 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,111 @@ 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()); + if (log.warn.restore) log.warn.restore(); + 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()); + if (log.warn.restore) log.warn.restore(); + 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()); + if (log.warn.restore) log.warn.restore(); + 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..a4a9676ac 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,32 @@ 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()); + if (log.warn.restore) log.warn.restore(); + 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..b55dfeedf 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,31 @@ 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()); + if (log.warn.restore) log.warn.restore(); + 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);