feat: sanitize paths when user input is used in path.join

This commit is contained in:
Nicolas Meienberger 2024-03-10 14:51:10 +01:00 committed by Nicolas Meienberger
parent d8934d7853
commit edf27af127
8 changed files with 47 additions and 37 deletions

View File

@ -0,0 +1,4 @@
export const sanitizePath = (inputPath: string) => {
// Remove any sequence of "../" or "./" to prevent directory traversal
return inputPath.replace(/(\.\.\/|\.\/)/g, '');
};

View File

@ -9,3 +9,4 @@ export { systemLoadSchema, type SystemLoad } from './schemas/system-schemas';
// Helpers
export { envMapToString, envStringToMap } from './helpers/env-helpers';
export { cleanseErrorData } from './helpers/error-helpers';
export { sanitizePath } from './helpers/sanitizers';

View File

@ -1,5 +1,6 @@
import path from 'path';
import { execAsync, pathExists } from '@runtipi/shared/node';
import { sanitizePath } from '@runtipi/shared';
import { logger } from '@/lib/logger';
import { getEnv } from '@/lib/environment';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
@ -22,13 +23,13 @@ const composeUp = async (args: string[]) => {
*/
export const compose = async (appId: string, command: string) => {
const { arch, appsRepoId } = getEnv();
const appDataDirPath = path.join(APP_DATA_DIR, appId);
const appDirPath = path.join(DATA_DIR, 'apps', appId);
const appDataDirPath = path.join(APP_DATA_DIR, sanitizePath(appId));
const appDirPath = path.join(DATA_DIR, 'apps', sanitizePath(appId));
const args: string[] = [`--env-file ${path.join(appDataDirPath, 'app.env')}`];
// User custom env file
const userEnvFile = path.join(DATA_DIR, 'user-config', appId, 'app.env');
const userEnvFile = path.join(DATA_DIR, 'user-config', sanitizePath(appId), 'app.env');
if (await pathExists(userEnvFile)) {
args.push(`--env-file ${userEnvFile}`);
}
@ -45,7 +46,7 @@ export const compose = async (appId: string, command: string) => {
args.push(`-f ${commonComposeFile}`);
// User defined overrides
const userComposeFile = path.join(DATA_DIR, 'user-config', appId, 'docker-compose.yml');
const userComposeFile = path.join(DATA_DIR, 'user-config', sanitizePath(appId), 'docker-compose.yml');
if (await pathExists(userComposeFile)) {
args.push(`--file ${userComposeFile}`);
}

View File

@ -4,7 +4,7 @@ import fs from 'fs';
import path from 'path';
import * as Sentry from '@sentry/node';
import { execAsync, pathExists } from '@runtipi/shared/node';
import { SocketEvent } from '@runtipi/shared';
import { SocketEvent, sanitizePath } from '@runtipi/shared';
import { copyDataDir, generateEnvFile } from './app.helpers';
import { logger } from '@/lib/logger';
import { compose } from '@/lib/docker';
@ -38,10 +38,10 @@ export class AppExecutors {
private getAppPaths = (appId: string) => {
const { appsRepoId } = getEnv();
const appDataDirPath = path.join(APP_DATA_DIR, appId);
const appDirPath = path.join(DATA_DIR, 'apps', appId);
const appDataDirPath = path.join(APP_DATA_DIR, sanitizePath(appId));
const appDirPath = path.join(DATA_DIR, 'apps', sanitizePath(appId));
const configJsonPath = path.join(appDirPath, 'config.json');
const repoPath = path.join(DATA_DIR, 'repos', appsRepoId, 'apps', appId);
const repoPath = path.join(DATA_DIR, 'repos', appsRepoId, 'apps', sanitizePath(appId));
return { appDataDirPath, appDirPath, configJsonPath, repoPath };
};
@ -53,7 +53,7 @@ export class AppExecutors {
*/
private ensureAppDir = async (appId: string) => {
const { appDirPath, appDataDirPath, repoPath } = this.getAppPaths(appId);
const dockerFilePath = path.join(DATA_DIR, 'apps', appId, 'docker-compose.yml');
const dockerFilePath = path.join(DATA_DIR, 'apps', sanitizePath(appId), 'docker-compose.yml');
if (!(await pathExists(dockerFilePath))) {
// delete eventual app folder if exists
@ -104,7 +104,7 @@ export class AppExecutors {
const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId);
// Check if app exists in repo
const apps = await fs.promises.readdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps'));
const apps = await fs.promises.readdir(path.join(DATA_DIR, 'repos', sanitizePath(appsRepoId), 'apps'));
if (!apps.includes(appId)) {
this.logger.error(`App ${appId} not found in repo ${appsRepoId}`);

View File

@ -1,7 +1,7 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { appInfoSchema, envMapToString, envStringToMap } from '@runtipi/shared';
import { appInfoSchema, envMapToString, envStringToMap, sanitizePath } from '@runtipi/shared';
import { pathExists, execAsync } from '@runtipi/shared/node';
import { generateVapidKeys, getAppEnvMap } from './env.helpers';
import { getEnv } from '@/lib/environment';
@ -38,7 +38,7 @@ const getEntropy = async (name: string, length: number) => {
export const generateEnvFile = async (appId: string, config: Record<string, unknown>) => {
const { internalIp, storagePath, rootFolderHost } = getEnv();
const configFile = await fs.promises.readFile(path.join(DATA_DIR, 'apps', appId, 'config.json'));
const configFile = await fs.promises.readFile(path.join(DATA_DIR, 'apps', sanitizePath(appId), 'config.json'));
const parsedConfig = appInfoSchema.safeParse(JSON.parse(configFile.toString()));
if (!parsedConfig.success) {
@ -52,7 +52,7 @@ export const generateEnvFile = async (appId: string, config: Record<string, unkn
envMap.set('APP_PORT', String(parsedConfig.data.port));
envMap.set('APP_ID', appId);
envMap.set('ROOT_FOLDER_HOST', rootFolderHost);
envMap.set('APP_DATA_DIR', path.join(storagePath, 'app-data', appId));
envMap.set('APP_DATA_DIR', path.join(storagePath, 'app-data', sanitizePath(appId)));
const existingEnvMap = await getAppEnvMap(appId);
@ -101,12 +101,12 @@ export const generateEnvFile = async (appId: string, config: Record<string, unkn
}
// Create app-data folder if it doesn't exist
const appDataDirectoryExists = await fs.promises.stat(path.join(APP_DATA_DIR, appId)).catch(() => false);
const appDataDirectoryExists = await fs.promises.stat(path.join(APP_DATA_DIR, sanitizePath(appId))).catch(() => false);
if (!appDataDirectoryExists) {
await fs.promises.mkdir(path.join(APP_DATA_DIR, appId), { recursive: true });
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(appId)), { recursive: true });
}
await fs.promises.writeFile(path.join(APP_DATA_DIR, appId, 'app.env'), envMapToString(envMap));
await fs.promises.writeFile(path.join(APP_DATA_DIR, sanitizePath(appId), 'app.env'), envMapToString(envMap));
};
/**
@ -136,40 +136,41 @@ export const copyDataDir = async (id: string) => {
const envMap = await getAppEnvMap(id);
// return if app does not have a data directory
if (!(await pathExists(`${DATA_DIR}/apps/${id}/data`))) {
if (!(await pathExists(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data')))) {
return;
}
// Create app-data folder if it doesn't exist
if (!(await pathExists(`${APP_DATA_DIR}/${id}/data`))) {
await fs.promises.mkdir(`${APP_DATA_DIR}/${id}/data`, { recursive: true });
if (!(await pathExists(path.join(APP_DATA_DIR, sanitizePath(id), 'data')))) {
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(id), 'data'), { recursive: true });
}
const dataDir = await fs.promises.readdir(`${DATA_DIR}/apps/${id}/data`);
const dataDir = await fs.promises.readdir(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data'));
const processFile = async (file: string) => {
if (file.endsWith('.template')) {
const template = await fs.promises.readFile(`${DATA_DIR}/apps/${id}/data/${file}`, 'utf-8');
const template = await fs.promises.readFile(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file), 'utf-8');
const renderedTemplate = renderTemplate(template, envMap);
await fs.promises.writeFile(`${APP_DATA_DIR}/${id}/data/${file.replace('.template', '')}`, renderedTemplate);
await fs.promises.writeFile(path.join(APP_DATA_DIR, sanitizePath(id), 'data', file.replace('.template', '')), renderedTemplate);
} else {
await fs.promises.copyFile(`${DATA_DIR}/apps/${id}/data/${file}`, `/app-data/${id}/data/${file}`);
await fs.promises.copyFile(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file), path.join(APP_DATA_DIR, sanitizePath(id), 'data', file));
}
};
const processDir = async (p: string) => {
await fs.promises.mkdir(`${APP_DATA_DIR}/${id}/data/${p}`, { recursive: true });
const files = await fs.promises.readdir(`${DATA_DIR}/apps/${id}/data/${p}`);
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(id), 'data', p), { recursive: true });
const files = await fs.promises.readdir(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', p));
await Promise.all(
files.map(async (file) => {
const fullPath = `${DATA_DIR}/apps/${id}/data/${p}/${file}`;
const fullPath = path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', p, file);
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(`${p}/${file}`);
await processDir(path.join(p, file));
} else {
await processFile(`${p}/${file}`);
await processFile(path.join(p, file));
}
}),
);
@ -177,7 +178,7 @@ export const copyDataDir = async (id: string) => {
await Promise.all(
dataDir.map(async (file) => {
const fullPath = `${DATA_DIR}/apps/${id}/data/${file}`;
const fullPath = path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file);
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(file);
@ -188,7 +189,7 @@ export const copyDataDir = async (id: string) => {
);
// Remove any .gitkeep files from the app-data folder at any level
if (await pathExists(`${APP_DATA_DIR}/${id}/data`)) {
await execAsync(`find ${APP_DATA_DIR}/${id}/data -name .gitkeep -delete`).catch(() => {});
if (await pathExists(path.join(APP_DATA_DIR, sanitizePath(id), 'data'))) {
await execAsync(`find ${APP_DATA_DIR}/${sanitizePath(id)}/data -name .gitkeep -delete`).catch(() => {});
}
};

View File

@ -1,6 +1,7 @@
import webpush from 'web-push';
import fs from 'fs';
import path from 'path';
import { sanitizePath } from '@runtipi/shared';
import { APP_DATA_DIR } from '@/config/constants';
/**
@ -11,7 +12,7 @@ import { APP_DATA_DIR } from '@/config/constants';
*/
export const getAppEnvMap = async (appId: string) => {
try {
const envFile = await fs.promises.readFile(path.join(APP_DATA_DIR, appId, 'app.env'));
const envFile = await fs.promises.readFile(path.join(APP_DATA_DIR, sanitizePath(appId), 'app.env'));
const envVars = envFile.toString().split('\n');
const envVarsMap = new Map<string, string>();

View File

@ -1,6 +1,7 @@
import path from 'path';
import { execAsync, pathExists } from '@runtipi/shared/node';
import * as Sentry from '@sentry/node';
import { sanitizePath } from '@runtipi/shared';
import { getRepoHash, getRepoBaseUrlAndBranch } from './repo.helpers';
import { logger } from '@/lib/logger';
import { DATA_DIR } from '@/config/constants';
@ -37,7 +38,7 @@ export class RepoExecutors {
// We may have a potential branch computed in the hash (see getRepoBaseUrlAndBranch)
// so we do it here before splitting the url into repoUrl and branch
const repoHash = getRepoHash(url);
const repoPath = path.join(DATA_DIR, 'repos', repoHash);
const repoPath = path.join(DATA_DIR, 'repos', sanitizePath(repoHash));
if (await pathExists(repoPath)) {
this.logger.info(`Repo ${url} already exists`);
@ -76,7 +77,7 @@ export class RepoExecutors {
public pullRepo = async (repoUrl: string) => {
try {
const repoHash = getRepoHash(repoUrl);
const repoPath = path.join(DATA_DIR, 'repos', repoHash);
const repoPath = path.join(DATA_DIR, 'repos', sanitizePath(repoHash));
if (!(await pathExists(repoPath))) {
this.logger.info(`Repo ${repoUrl} does not exist`);

View File

@ -3,7 +3,8 @@ import { TipiConfig } from '@/server/core/TipiConfig/TipiConfig';
import { pathExists } from '@runtipi/shared/node';
import fs from 'fs-extra';
import path from 'path';
import { APP_DIR, DATA_DIR } from 'src/config';
import { sanitizePath } from '@runtipi/shared';
import { APP_DIR, DATA_DIR } from '../../../config/constants';
export async function GET(request: Request) {
try {
@ -14,8 +15,8 @@ export async function GET(request: Request) {
return new Response('Not found', { status: 404 });
}
const defaultFilePath = path.join(DATA_DIR, 'apps', id, 'metadata', 'logo.jpg');
const appRepoFilePath = path.join(DATA_DIR, 'repos', TipiConfig.getConfig().appsRepoId, 'apps', id, 'metadata', 'logo.jpg');
const defaultFilePath = path.join(DATA_DIR, 'apps', sanitizePath(id), 'metadata', 'logo.jpg');
const appRepoFilePath = path.join(DATA_DIR, 'repos', TipiConfig.getConfig().appsRepoId, 'apps', sanitizePath(id), 'metadata', 'logo.jpg');
let filePath = path.join(APP_DIR, 'public', 'app-not-found.jpg');