Skip to content
25 changes: 12 additions & 13 deletions src/context/directory/handlers/actionModules.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,22 +24,21 @@ 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)) {
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)) {
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}"]`
`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.`
);
module.code = context.loadFile(unixPath, moduleFolder);
} else {
module.code = context.loadFile(path.join(context.filePath, module.code), moduleFolder);
}
module.code = loadFileAndReplaceKeywords(resolvedPath, {
mappings: context.mappings,
disableKeywordReplacement: context.disableKeywordReplacement,
});
}

return module;
Expand Down
26 changes: 12 additions & 14 deletions src/context/directory/handlers/actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,23 +25,21 @@ 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
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)) {
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}"]`
`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.`
);
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);
}
action.code = loadFileAndReplaceKeywords(resolvedPath, {
mappings: context.mappings,
disableKeywordReplacement: context.disableKeywordReplacement,
});
}

return action;
Expand Down
8 changes: 4 additions & 4 deletions src/context/directory/handlers/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ 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}"]`
`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.`
);
}
database.options.customScripts[name] = loadFileAndReplaceKeywords(toLoad, mappingOpts);
Expand Down
21 changes: 19 additions & 2 deletions src/context/directory/handlers/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,7 +24,24 @@ 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)) {
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.`
);
}
hook.script = loadFileAndReplaceKeywords(resolvedPath, {
mappings: context.mappings,
disableKeywordReplacement: context.disableKeywordReplacement,
});
}

hook.name = hook.name.toLowerCase().replace(/\s/g, '-');
Expand Down
21 changes: 19 additions & 2 deletions src/context/directory/handlers/rules.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,7 +25,24 @@ 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)) {
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.`
);
}
rule.script = loadFileAndReplaceKeywords(resolvedPath, {
mappings: context.mappings,
disableKeywordReplacement: context.disableKeywordReplacement,
});
}
return rule;
});
Expand Down
17 changes: 8 additions & 9 deletions src/context/directory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,15 +47,14 @@ 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;
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)) {
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}"]`
`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.`
);
}
return loadFileAndReplaceKeywords(toLoad, {
Expand Down
17 changes: 8 additions & 9 deletions src/context/yaml/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,17 +58,16 @@ 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;
const configRoot = path.resolve(this.basePath);
const toLoad = path.resolve(this.basePath, f.replace(/\\/g, '/'));
if (!toLoad.startsWith(configRoot + 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: ["${f}"]`
`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.`
);
}
return loadFileAndReplaceKeywords(path.resolve(toLoad), {
return loadFileAndReplaceKeywords(toLoad, {
mappings: this.mappings,
disableKeywordReplacement: this.disableKeywordReplacement,
});
Expand Down
32 changes: 32 additions & 0 deletions test/context/directory/actionModules.test.ts
Original file line number Diff line number Diff line change
@@ -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/actionModules';
Expand Down Expand Up @@ -94,6 +96,36 @@ describe('#directory context actionModules', () => {
.and.have.property('message', errorMessage);
});

it('should warn when module code path resolves outside the config directory', async () => {
const repoDir = path.join(testDataDir, 'directory', 'actionModules-traversal-warn');
const outsideFile = path.join(testDataDir, 'directory', 'outside-module-code.js');
fs.ensureDirSync(path.join(repoDir, constants.ACTION_MODULES_DIRECTORY));
fs.writeFileSync(outsideFile, 'module.exports = {};');
createDir(repoDir, {
[constants.ACTION_MODULES_DIRECTORY]: {
'module-one.json': JSON.stringify({
name: 'module-one',
code: '../outside-module-code.js',
dependencies: [],
secrets: [],
}),
},
});
const context = new Context({ AUTH0_INPUT_FILE: repoDir }, 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 action modules', async () => {
const moduleName = 'module-one';
const dir = path.join(testDataDir, 'directory', 'actionModules4');
Expand Down
75 changes: 73 additions & 2 deletions test/context/directory/actions.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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": [
{
Expand All @@ -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": [
{
Expand Down Expand Up @@ -351,6 +353,75 @@ 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 dump actions with modules', async () => {
const actionName = 'action-with-modules';
const dir = path.join(testDataDir, 'directory', 'test-action-modules');
Expand Down
Loading