Harden path handling in backend

pull/1163/head
voc0der 2 months ago
parent 84537e8e72
commit 788fcdcef6

@ -409,16 +409,33 @@ async function downloadReleaseFiles(tag) {
// get public folder files
const actualFileName = fileName.replace('youtubedl-material/public/', '');
if (actualFileName.length !== 0 && actualFileName.substring(actualFileName.length-1, actualFileName.length) !== '/') {
fs.ensureDirSync(path.join(__dirname, 'public', path.dirname(actualFileName)));
entry.pipe(fs.createWriteStream(path.join(__dirname, 'public', actualFileName)));
const publicBasePath = path.join(__dirname, 'public');
const targetPublicPath = path.resolve(publicBasePath, actualFileName);
const relativePublicPath = path.relative(publicBasePath, targetPublicPath);
if (relativePublicPath.startsWith('..') || path.isAbsolute(relativePublicPath)) {
logger.warn(`Skipping unsafe public file path during update extraction: ${actualFileName}`);
entry.autodrain();
return;
}
fs.ensureDirSync(path.dirname(targetPublicPath));
entry.pipe(fs.createWriteStream(targetPublicPath));
} else {
entry.autodrain();
}
} else if (!is_dir && !replace_ignore_list.includes(fileName)) {
// get package.json
const actualFileName = fileName.replace('youtubedl-material/', '');
const repoBasePath = path.resolve(__dirname);
const targetFilePath = path.resolve(repoBasePath, actualFileName);
const relativeRepoPath = path.relative(repoBasePath, targetFilePath);
if (relativeRepoPath.startsWith('..') || path.isAbsolute(relativeRepoPath)) {
logger.warn(`Skipping unsafe file path during update extraction: ${actualFileName}`);
entry.autodrain();
return;
}
logger.verbose('Downloading file ' + actualFileName);
entry.pipe(fs.createWriteStream(path.join(__dirname, actualFileName)));
entry.pipe(fs.createWriteStream(targetFilePath));
} else {
entry.autodrain();
}
@ -450,7 +467,7 @@ async function downloadReleaseZip(tag) {
resolve(false);
return;
}
await utils.writeFetchResponseToFile(res, output_path, 'update ' + tag);
await utils.writeFetchResponseToFile(res, fs.createWriteStream(output_path), 'update ' + tag);
resolve(true);
});
@ -1624,10 +1641,23 @@ app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => {
var upload_multer = multer({ dest: __dirname + '/appdata/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
if (!req.file || !req.file.path) {
res.sendStatus(400);
return;
}
const new_path = path.join(__dirname, 'appdata', 'cookies.txt');
const uploadBasePath = path.join(__dirname, 'appdata');
const resolvedUploadedPath = path.resolve(req.file.path);
const relativeUploadedPath = path.relative(uploadBasePath, resolvedUploadedPath);
if (relativeUploadedPath.startsWith('..') || path.isAbsolute(relativeUploadedPath)) {
logger.error(`Refusing to move uploaded cookies file outside appdata: ${req.file.path}`);
res.sendStatus(400);
return;
}
if (await fs.pathExists(req.file.path)) {
await fs.rename(req.file.path, new_path);
if (await fs.pathExists(resolvedUploadedPath)) {
await fs.rename(resolvedUploadedPath, new_path);
} else {
res.sendStatus(500);
return;
@ -1929,9 +1959,41 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
});
app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
let file_path = decodeURIComponent(req.params.path);
if (fs.existsSync(file_path)) path.isAbsolute(file_path) ? res.sendFile(file_path) : res.sendFile(path.join(__dirname, file_path));
else res.sendStatus(404);
const requestedPath = decodeURIComponent(req.params.path || '');
const resolvedRequestedPath = path.isAbsolute(requestedPath) ? path.resolve(requestedPath) : path.resolve(__dirname, requestedPath);
const thumbnailExt = path.extname(resolvedRequestedPath).toLowerCase();
const allowedThumbnailExts = new Set(['.jpg', '.jpeg', '.png', '.webp']);
if (!allowedThumbnailExts.has(thumbnailExt)) {
res.sendStatus(400);
return;
}
const configuredRoots = [
__dirname,
config_api.getConfigItem('ytdl_audio_folder_path'),
config_api.getConfigItem('ytdl_video_folder_path'),
config_api.getConfigItem('ytdl_subscriptions_base_path'),
config_api.getConfigItem('ytdl_users_base_path')
]
.filter(Boolean)
.map(root => path.resolve(__dirname, root));
const pathAllowed = configuredRoots.some(root => {
const relativePath = path.relative(root, resolvedRequestedPath);
return relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
});
if (!pathAllowed) {
logger.warn(`Refusing thumbnail request outside allowed roots: ${requestedPath}`);
res.sendStatus(403);
return;
}
if (fs.existsSync(resolvedRequestedPath)) {
res.sendFile(resolvedRequestedPath);
} else {
res.sendStatus(404);
}
});
// Downloads management

@ -130,7 +130,13 @@ exports.registerUser = async (userid, username, plaintextPassword) => {
exports.deleteUser = async (uid) => {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const usersBaseFolder = path.join(__dirname, usersFileFolder);
const user_folder = path.join(usersBaseFolder, uid);
const relativeUserFolder = path.relative(usersBaseFolder, user_folder);
if (relativeUserFolder.startsWith('..') || path.isAbsolute(relativeUserFolder)) {
logger.error(`Refusing to delete user folder with unsafe uid path: ${uid}`);
return false;
}
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete

@ -727,7 +727,13 @@ exports.backupDB = async () => {
}
exports.restoreDB = async (file_name) => {
const path_to_backup = path.join('appdata', 'db_backup', file_name);
const backup_dir = path.join('appdata', 'db_backup');
const path_to_backup = path.join(backup_dir, file_name);
const relative_backup_path = path.relative(backup_dir, path_to_backup);
if (!file_name || path.basename(file_name) !== file_name || relative_backup_path.startsWith('..') || path.isAbsolute(relative_backup_path)) {
logger.error(`Failed to restore DB! Unsafe backup file name '${file_name}'.`);
return false;
}
logger.debug('Reading database backup file.');
const table_to_records = fs.readJSONSync(path_to_backup);
@ -844,4 +850,4 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
// should only be used for tests
exports.setLocalDBMode = (mode) => {
using_local_db = mode;
}
}

@ -543,9 +543,17 @@ exports.writeSubscriptionMetadata = (sub) => {
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
const resolvedBasePath = path.resolve(basePath);
const resolvedSubscriptionPath = path.resolve(appendedBasePath);
const relativeSubscriptionPath = path.relative(resolvedBasePath, resolvedSubscriptionPath);
if (relativeSubscriptionPath.startsWith('..') || path.isAbsolute(relativeSubscriptionPath)) {
logger.error(`Refusing to write subscription metadata outside subscriptions directory for subscription '${sub && sub.name}'.`);
return;
}
const metadata_path = path.resolve(resolvedSubscriptionPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.ensureDirSync(appendedBasePath);
fs.ensureDirSync(resolvedSubscriptionPath);
fs.writeJSONSync(metadata_path, sub);
}

@ -201,6 +201,10 @@ exports.executeConfirm = async (task_key) => {
exports.updateTaskSchedule = async (task_key, schedule) => {
logger.verbose(`Updating schedule for task ${task_key}`);
if (!Object.prototype.hasOwnProperty.call(TASKS, task_key)) {
logger.error(`Cannot update schedule for unknown task key '${task_key}'.`);
return false;
}
await db_api.updateRecord('tasks', {key: task_key}, {schedule: schedule});
if (TASKS[task_key]['job']) {
TASKS[task_key]['job'].cancel();
@ -340,4 +344,4 @@ const guessSubscriptions = async (isPlaylist, basePath = null) => {
return guessed_subs;
}
exports.TASKS = TASKS;
exports.TASKS = TASKS;

@ -16,6 +16,7 @@ async function getCommentsForVOD(vodId) {
logger.error('VOD ID must be purely alphanumeric. Twitch chat download failed!');
return null;
}
const safeVodId = path.basename(vodId);
const is_windows = process.platform === 'win32';
const cliExt = is_windows ? '.exe' : ''
@ -26,17 +27,24 @@ async function getCommentsForVOD(vodId) {
return null;
}
const result = await exec(`${cliPath} chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
const result = await exec(`${cliPath} chatdownload -u ${safeVodId} -o appdata/${safeVodId}.json`, {stdio:[0,1,2]});
if (result['stderr']) {
logger.error(`Failed to download twitch comments for ${vodId}`);
logger.error(`Failed to download twitch comments for ${safeVodId}`);
logger.error(result['stderr']);
return null;
}
const temp_chat_path = path.join('appdata', `${vodId}.json`);
const temp_chat_path = path.join('appdata', `${safeVodId}.json`);
const appdataBasePath = path.resolve('appdata');
const resolvedTempChatPath = path.resolve(temp_chat_path);
const relativeTempChatPath = path.relative(appdataBasePath, resolvedTempChatPath);
if (relativeTempChatPath.startsWith('..') || path.isAbsolute(relativeTempChatPath)) {
logger.error(`Refusing to access temporary twitch chat file outside appdata for ${safeVodId}`);
return null;
}
const raw_json = fs.readJSONSync(temp_chat_path);
const raw_json = fs.readJSONSync(resolvedTempChatPath);
const new_json = raw_json.comments.map(comment_obj => {
return {
timestamp: comment_obj.content_offset_seconds,
@ -47,7 +55,7 @@ async function getCommentsForVOD(vodId) {
}
});
fs.unlinkSync(temp_chat_path);
fs.unlinkSync(resolvedTempChatPath);
return new_json;
}
@ -56,25 +64,43 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
let file_path = null;
let base_path = null;
const safeType = type === 'audio' || type === 'video' ? type : null;
if (user_uid) {
if (sub) {
base_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels');
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
if (!safeType) return null;
base_path = path.join(usersFileFolder, user_uid, safeType);
file_path = path.join(usersFileFolder, user_uid, safeType, `${id}.twitch_chat.json`);
}
} else {
if (sub) {
base_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels');
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
const typeFolder = config_api.getConfigItem(`ytdl_${type}_folder_path`);
if (!safeType) return null;
const typeFolder = config_api.getConfigItem(`ytdl_${safeType}_folder_path`);
base_path = typeFolder;
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
}
}
var chat_file = null;
if (fs.existsSync(file_path)) {
chat_file = fs.readJSONSync(file_path);
if (file_path && base_path) {
const resolvedBasePath = path.resolve(base_path);
const resolvedFilePath = path.resolve(file_path);
const relativeFilePath = path.relative(resolvedBasePath, resolvedFilePath);
if (relativeFilePath.startsWith('..') || path.isAbsolute(relativeFilePath)) {
logger.error(`Refusing to read twitch chat outside expected directory for file id '${id}'.`);
return null;
}
if (fs.existsSync(resolvedFilePath)) {
chat_file = fs.readJSONSync(resolvedFilePath);
}
}
return chat_file;
@ -87,23 +113,42 @@ async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customF
// save file if needed params are included
let file_path = null;
let base_path = null;
const safeType = type === 'audio' || type === 'video' ? type : null;
if (customFileFolderPath) {
base_path = customFileFolderPath;
file_path = path.join(customFileFolderPath, `${id}.twitch_chat.json`)
} else if (user_uid) {
if (sub) {
base_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels');
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
if (!safeType) return null;
base_path = path.join(usersFileFolder, user_uid, safeType);
file_path = path.join(usersFileFolder, user_uid, safeType, `${id}.twitch_chat.json`);
}
} else {
if (sub) {
base_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels');
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join(type, `${id}.twitch_chat.json`);
if (!safeType) return null;
const typeFolder = config_api.getConfigItem(`ytdl_${safeType}_folder_path`);
base_path = typeFolder;
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
}
}
if (chat) fs.writeJSONSync(file_path, chat);
if (chat && file_path && base_path) {
const resolvedBasePath = path.resolve(base_path);
const resolvedFilePath = path.resolve(file_path);
const relativeFilePath = path.relative(resolvedBasePath, resolvedFilePath);
if (relativeFilePath.startsWith('..') || path.isAbsolute(relativeFilePath)) {
logger.error(`Refusing to write twitch chat outside expected directory for file id '${id}'.`);
return null;
}
fs.writeJSONSync(resolvedFilePath, chat);
}
return chat;
}
@ -120,4 +165,4 @@ module.exports = {
getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID,
downloadTwitchChatByVODID: downloadTwitchChatByVODID
}
}

@ -358,7 +358,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
}
// helper function to write an already-fetched response body to disk
exports.writeFetchResponseToFile = async (res, path, file_label) => {
exports.writeFetchResponseToFile = async (res, fileStream, file_label) => {
var len = null;
len = parseInt(res.headers.get("Content-Length"), 10);
@ -368,7 +368,6 @@ exports.writeFetchResponseToFile = async (res, path, file_label) => {
width: 20,
total: len
});
const fileStream = fs.createWriteStream(path);
await new Promise((resolve, reject) => {
res.body.pipe(fileStream);
res.body.on("error", (err) => {

@ -168,7 +168,7 @@ async function downloadLatestYoutubeDLBinaryGeneric(youtubedl_fork, new_version,
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
await utils.writeFetchResponseToFile(res, output_path, `${youtubedl_fork} ${new_version}`);
await utils.writeFetchResponseToFile(res, fs.createWriteStream(output_path), `${youtubedl_fork} ${new_version}`);
fs.chmod(output_path, 0o777);
updateDetailsJSON(new_version, youtubedl_fork, output_path);

Loading…
Cancel
Save