Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e8d7553ea | ||
|
|
e1fb345094 | ||
|
|
351f1876d7 | ||
|
|
c32fa3e7ca |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,6 +1,23 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
## v1.7.22 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add risk command scanning for hook uploads (c32fa3e)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve handling of absolute paths in security functions (351f187)
|
||||
|
||||
## v1.7.21 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add hook functionality for task lifecycle (6f1a1cd)
|
||||
- feat: add task hooks (8ac9e99)
|
||||
|
||||
## v1.7.20 (2025-12-30)
|
||||
|
||||
### Chore
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -21,6 +21,16 @@ describe('security', () => {
|
||||
it('should return false for traversal', () => {
|
||||
expect(security.validatePathWithinDirectory('/base/../other/file.txt', '/base')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle absolute paths correctly without duplication', () => {
|
||||
// Mock path.resolve to behave predictably for testing logic if needed,
|
||||
// but here we rely on the implementation fix.
|
||||
// This tests that if we pass an absolute path that is valid, it returns true.
|
||||
// The critical part is that it doesn't fail internally or double-resolve.
|
||||
const absPath = '/Users/user/project/backend/uploads/videos/test.mp4';
|
||||
const allowedDir = '/Users/user/project/backend/uploads/videos';
|
||||
expect(security.validatePathWithinDirectory(absPath, allowedDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
|
||||
@@ -28,10 +28,54 @@ export const uploadHook = async (
|
||||
throw new ValidationError("Invalid hook name", "name");
|
||||
}
|
||||
|
||||
// Scan for risk commands
|
||||
const riskCommand = scanForRiskCommands(req.file.path);
|
||||
if (riskCommand) {
|
||||
// Delete the file immediately
|
||||
require("fs").unlinkSync(req.file.path);
|
||||
throw new ValidationError(
|
||||
`Risk command detected: ${riskCommand}. Upload rejected.`,
|
||||
"file"
|
||||
);
|
||||
}
|
||||
|
||||
HookService.uploadHook(name, req.file.path);
|
||||
res.json(successMessage(`Hook ${name} uploaded successfully`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan file for risk commands
|
||||
*/
|
||||
const scanForRiskCommands = (filePath: string): string | null => {
|
||||
const fs = require("fs");
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// List of risky patterns
|
||||
// We use regex to match commands, trying to avoid false positives in comments if possible,
|
||||
// but for safety, even commented dangerous commands might be flagged or we just accept strictness.
|
||||
// A simple include check is safer for now.
|
||||
const riskyPatterns = [
|
||||
{ pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+)*-?[rf][a-zA-Z]*\s+.*[\/\*]/, name: "rm -rf / (recursive delete)" }, // Matches rm -rf /, rm -fr *, etc roughly
|
||||
{ pattern: /mkfs/, name: "mkfs (format disk)" },
|
||||
{ pattern: /dd\s+if=/, name: "dd (disk write)" },
|
||||
{ pattern: /:[:\(\)\{\}\s|&]+;:/, name: "fork bomb" },
|
||||
{ pattern: />\s*\/dev\/sd/, name: "write to block device" },
|
||||
{ pattern: />\s*\/dev\/nvme/, name: "write to block device" },
|
||||
{ pattern: /mv\s+.*[\s\/]+\//, name: "mv to root" }, // deeply simplified, but mv / is dangerous
|
||||
{ pattern: /chmod\s+.*777\s+\//, name: "chmod 777 root" },
|
||||
{ pattern: /wget\s+http/, name: "wget (potential malware download)" },
|
||||
{ pattern: /curl\s+http/, name: "curl (potential malware download)" },
|
||||
];
|
||||
|
||||
for (const risk of riskyPatterns) {
|
||||
if (risk.pattern.test(content)) {
|
||||
return risk.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete hook script
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
uploadHook,
|
||||
} from "../controllers/hookController";
|
||||
import {
|
||||
getPasswordEnabled
|
||||
getPasswordEnabled,
|
||||
verifyPassword
|
||||
} from "../controllers/passwordController";
|
||||
import {
|
||||
deleteLegacyData,
|
||||
@@ -43,6 +44,7 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
|
||||
|
||||
// Password routes
|
||||
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
||||
router.post("/verify-password", asyncHandler(verifyPassword));
|
||||
|
||||
// ... existing imports ...
|
||||
|
||||
|
||||
@@ -58,8 +58,12 @@ export function validatePathWithinDirectory(
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
@@ -130,8 +134,12 @@ export function resolveSafePath(filePath: string, allowedDir: string): string {
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Settings } from '../../types';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
interface HookSettingsProps {
|
||||
settings: Settings;
|
||||
@@ -17,6 +18,11 @@ const API_URL = import.meta.env.VITE_API_URL;
|
||||
const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
const { t } = useLanguage();
|
||||
const [deleteHookName, setDeleteHookName] = useState<string | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [pendingUpload, setPendingUpload] = useState<{ hookName: string; file: File } | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const { data: hookStatus, refetch: refetchHooks, isLoading } = useQuery({
|
||||
queryKey: ['hookStatus'],
|
||||
@@ -36,6 +42,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchHooks();
|
||||
setPendingUpload(null);
|
||||
setUploadError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Upload failed:', error);
|
||||
const message = error.response?.data?.message || error.message;
|
||||
// Try to match risk command error
|
||||
// Backend sends: "Risk command detected: {command}. Upload rejected."
|
||||
const riskMatch = message?.match(/Risk command detected: (.*)\. Upload rejected\./);
|
||||
if (riskMatch && riskMatch[1]) {
|
||||
setUploadError(t('riskCommandDetected', { command: riskMatch[1] }));
|
||||
} else {
|
||||
setUploadError(message || t('uploadFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,10 +78,35 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ hookName, file });
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
|
||||
setPendingUpload({ hookName, file });
|
||||
setPasswordError(undefined);
|
||||
setUploadError(null);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifying(true);
|
||||
setPasswordError(undefined);
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
setShowPasswordModal(false);
|
||||
if (pendingUpload) {
|
||||
uploadMutation.mutate(pendingUpload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response?.status === 429) {
|
||||
const waitTime = error.response.data.waitTime;
|
||||
setPasswordError(t('tooManyAttempts') + ` Try again in ${Math.ceil(waitTime / 1000)}s`);
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword'));
|
||||
}
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (hookName: string) => {
|
||||
@@ -109,6 +154,12 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
{t('taskHooksWarning')}
|
||||
</Alert>
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setUploadError(null)}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
@@ -185,6 +236,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingUpload(null);
|
||||
setPasswordError(undefined);
|
||||
}}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('enterPassword')}
|
||||
message={t('enterPasswordToUploadHook') || 'Please enter your password to upload this hook script.'}
|
||||
error={passwordError}
|
||||
isLoading={isVerifying}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -615,6 +615,8 @@ export const ar = {
|
||||
taskHooks: 'خطافات المهام',
|
||||
taskHooksDescription: 'نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.',
|
||||
enterPasswordToUploadHook: 'الرجاء إدخال كلمة المرور لتحميل نص Hook هذا.',
|
||||
riskCommandDetected: 'تم اكتشاف أمر خطر: {command}. تم رفض التحميل.',
|
||||
hookTaskBeforeStart: 'قبل بدء المهمة',
|
||||
hookTaskBeforeStartHelper: 'ينفذ قبل بدء التنزيل.',
|
||||
hookTaskSuccess: 'نجاح المهمة',
|
||||
|
||||
@@ -598,6 +598,8 @@ export const de = {
|
||||
taskHooks: 'Aufgaben-Hoks',
|
||||
taskHooksDescription: 'Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.',
|
||||
enterPasswordToUploadHook: 'Bitte geben Sie Ihr Passwort ein, um dieses Hook-Skript hochzuladen.',
|
||||
riskCommandDetected: 'Risikobefehl erkannt: {command}. Upload abgelehnt.',
|
||||
hookTaskBeforeStart: 'Vor Aufgabenstart',
|
||||
hookTaskBeforeStartHelper: 'Wird ausgeführt, bevor der Download beginnt.',
|
||||
hookTaskSuccess: 'Aufgabe Erfolgreich',
|
||||
|
||||
@@ -132,6 +132,8 @@ export const en = {
|
||||
deleteHook: 'Delete Hook Script',
|
||||
confirmDeleteHook: 'Are you sure you want to delete this hook script?',
|
||||
uploadHook: 'Upload .sh',
|
||||
enterPasswordToUploadHook: 'Please enter your password to upload this hook script.',
|
||||
riskCommandDetected: 'Risk command detected: {command}. Upload rejected.',
|
||||
cleanupTempFilesActiveDownloads:
|
||||
"Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
|
||||
formatFilenamesSuccess:
|
||||
|
||||
@@ -647,6 +647,8 @@ export const fr = {
|
||||
taskHooks: 'Crochets de Tâche',
|
||||
taskHooksDescription: 'Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d\'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Avertissement : Les commandes s\'exécutent avec les permissions du serveur. À utiliser avec prudence.',
|
||||
enterPasswordToUploadHook: 'Veuillez entrer votre mot de passe pour télécharger ce script Hook.',
|
||||
riskCommandDetected: 'Commande à risque détectée : {command}. Téléchargement rejeté.',
|
||||
hookTaskBeforeStart: 'Avant le Début de la Tâche',
|
||||
hookTaskBeforeStartHelper: 'S\'exécute avant le début du téléchargement.',
|
||||
hookTaskSuccess: 'Tâche Réussie',
|
||||
|
||||
@@ -626,6 +626,8 @@ export const ja = {
|
||||
taskHooks: 'タスクフック',
|
||||
taskHooksDescription: 'タスクライフサイクルの特定のポイントでカスタムシェルコマンドを実行します。利用可能な環境変数: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。',
|
||||
taskHooksWarning: '警告: コマンドはサーバーの権限で実行されます。注意して使用してください。',
|
||||
enterPasswordToUploadHook: 'このフック・スクリプトをアップロードするにはパスワードを入力してください。',
|
||||
riskCommandDetected: '危険なコマンドが検出されました: {command}。アップロードは拒否されました。',
|
||||
hookTaskBeforeStart: 'タスク開始前',
|
||||
hookTaskBeforeStartHelper: 'ダウンロードが始まる前に実行されます。',
|
||||
hookTaskSuccess: 'タスク成功',
|
||||
|
||||
@@ -614,6 +614,8 @@ export const ko = {
|
||||
taskHooks: '태스크 훅',
|
||||
taskHooksDescription: '태스크 수명 주기의 특정 지점에서 사용자 지정 셸 명령을 실행합니다. 사용 가능한 환경 변수: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: '경고: 명령은 서버 권한으로 실행됩니다. 주의해서 사용하십시오.',
|
||||
enterPasswordToUploadHook: '이 훅 스크립트를 업로드하려면 비밀번호를 입력하십시오.',
|
||||
riskCommandDetected: '위험한 명령 감지됨: {command}. 업로드 거부됨.',
|
||||
hookTaskBeforeStart: '태스크 시작 전',
|
||||
hookTaskBeforeStartHelper: '다운로드가 시작되기 전에 실행됩니다.',
|
||||
hookTaskSuccess: '태스크 성공',
|
||||
|
||||
@@ -625,6 +625,8 @@ export const pt = {
|
||||
taskHooks: 'Ganchos de Tarefa',
|
||||
taskHooksDescription: 'Execute comandos shell personalizados em pontos específicos do ciclo de vida da tarefa. Variáveis de ambiente disponíveis: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Aviso: Os comandos são executados com as permissões do servidor. Use com cautela.',
|
||||
enterPasswordToUploadHook: 'Por favor, digite sua senha para fazer upload deste script Hook.',
|
||||
riskCommandDetected: 'Comando de risco detectado: {command}. Upload rejeitado.',
|
||||
hookTaskBeforeStart: 'Antes do Início da Tarefa',
|
||||
hookTaskBeforeStartHelper: 'Executa antes do download começar.',
|
||||
hookTaskSuccess: 'Tarefa com Sucesso',
|
||||
|
||||
@@ -620,6 +620,8 @@ export const ru = {
|
||||
taskHooks: 'Хуки Задач',
|
||||
taskHooksDescription: 'Выполняйте пользовательские shell-команды в определенные моменты жизненного цикла задачи. Доступные переменные окружения: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Предупреждение: Команды выполняются с правами сервера. Используйте с осторожностью.',
|
||||
enterPasswordToUploadHook: 'Пожалуйста, введите пароль для загрузки этого Hook-скрипта.',
|
||||
riskCommandDetected: 'Обнаружена опасная команда: {command}. Загрузка отклонена.',
|
||||
hookTaskBeforeStart: 'Перед Началом Задачи',
|
||||
hookTaskBeforeStartHelper: 'Выполняется перед началом загрузки.',
|
||||
hookTaskSuccess: 'Успех Задачи',
|
||||
|
||||
@@ -612,6 +612,8 @@ export const zh = {
|
||||
taskHooks: '任务钩子',
|
||||
taskHooksDescription: '在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。',
|
||||
taskHooksWarning: '警告:命令将以服务器权限运行。请谨慎使用。',
|
||||
enterPasswordToUploadHook: '请输入密码以上传此 Hook 脚本。',
|
||||
riskCommandDetected: '检测到危险命令:{command}。上传已拒绝。',
|
||||
hookTaskBeforeStart: '任务开始前',
|
||||
hookTaskBeforeStartHelper: '在下载开始前执行。',
|
||||
hookTaskSuccess: '任务成功',
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"description": "Multiple platform video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user