diff --git a/.gitignore b/.gitignore index 54414df..fa66863 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ backend/data/* *.mp4 *.mkv *.avi + +# Snyk Security Extension - AI Rules (auto-generated) +.cursor/rules/snyk_rules.mdc diff --git a/backend/src/controllers/videoController.ts b/backend/src/controllers/videoController.ts index f8bb829..e02d155 100644 --- a/backend/src/controllers/videoController.ts +++ b/backend/src/controllers/videoController.ts @@ -1,4 +1,3 @@ -import { exec } from "child_process"; import { Request, Response } from "express"; import fs from "fs-extra"; import multer from "multer"; @@ -9,6 +8,11 @@ import { getVideoDuration } from "../services/metadataService"; import * as storageService from "../services/storageService"; import { logger } from "../utils/logger"; import { successResponse } from "../utils/response"; +import { + execFileSafe, + validateImagePath, + validateVideoPath, +} from "../utils/security"; // Configure Multer for file uploads const storage = multer.diskStorage({ @@ -22,7 +26,13 @@ const storage = multer.diskStorage({ }, }); -export const upload = multer({ storage: storage }); +// Configure multer with large file size limit (100GB) +export const upload = multer({ + storage: storage, + limits: { + fileSize: 100 * 1024 * 1024 * 1024, // 10GB in bytes + }, +}); /** * Get all videos @@ -113,21 +123,25 @@ export const uploadVideo = async ( const videoPath = path.join(VIDEOS_DIR, videoFilename); const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename); - // Generate thumbnail - await new Promise((resolve, _reject) => { - exec( - `ffmpeg -i "${videoPath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`, - (error) => { - if (error) { - logger.error("Error generating thumbnail:", error); - // We resolve anyway to not block the upload, just without a custom thumbnail - resolve(); - } else { - resolve(); - } - } - ); - }); + // Validate paths to prevent path traversal + const validatedVideoPath = validateVideoPath(videoPath); + const validatedThumbnailPath = validateImagePath(thumbnailPath); + + // Generate thumbnail using execFileSafe to prevent command injection + try { + await execFileSafe("ffmpeg", [ + "-i", + validatedVideoPath, + "-ss", + "00:00:00", + "-vframes", + "1", + validatedThumbnailPath, + ]); + } catch (error) { + logger.error("Error generating thumbnail:", error); + // Continue without thumbnail - don't block the upload + } // Get video duration const duration = await getVideoDuration(videoPath); @@ -226,7 +240,9 @@ export const getAuthorChannelUrl = async ( // First, check if we have the video in the database with a stored channelUrl const existingVideo = storageService.getVideoBySourceUrl(sourceUrl); if (existingVideo && existingVideo.channelUrl) { - res.status(200).json({ success: true, channelUrl: existingVideo.channelUrl }); + res + .status(200) + .json({ success: true, channelUrl: existingVideo.channelUrl }); return; } @@ -261,10 +277,12 @@ export const getAuthorChannelUrl = async ( // If we have the video in database, try to get channelUrl from there first // (already checked above, but this is for clarity) if (existingVideo && existingVideo.channelUrl) { - res.status(200).json({ success: true, channelUrl: existingVideo.channelUrl }); + res + .status(200) + .json({ success: true, channelUrl: existingVideo.channelUrl }); return; } - + const axios = (await import("axios")).default; const { extractBilibiliVideoId } = await import("../utils/helpers"); @@ -295,12 +313,14 @@ export const getAuthorChannelUrl = async ( ) { const mid = response.data.data.owner.mid; const spaceUrl = `https://space.bilibili.com/${mid}`; - + // If we have the video in database, update it with the channelUrl if (existingVideo) { - storageService.updateVideo(existingVideo.id, { channelUrl: spaceUrl }); + storageService.updateVideo(existingVideo.id, { + channelUrl: spaceUrl, + }); } - + res.status(200).json({ success: true, channelUrl: spaceUrl }); return; } diff --git a/backend/src/controllers/videoMetadataController.ts b/backend/src/controllers/videoMetadataController.ts index ff29d70..c517eb7 100644 --- a/backend/src/controllers/videoMetadataController.ts +++ b/backend/src/controllers/videoMetadataController.ts @@ -1,4 +1,3 @@ -import { exec } from "child_process"; import { Request, Response } from "express"; import fs from "fs-extra"; import path from "path"; @@ -7,6 +6,7 @@ import { NotFoundError, ValidationError } from "../errors/DownloadErrors"; import * as storageService from "../services/storageService"; import { logger } from "../utils/logger"; import { successResponse } from "../utils/response"; +import { execFileSafe, validateVideoPath, validateImagePath } from "../utils/security"; /** * Rate video @@ -91,21 +91,23 @@ export const refreshThumbnail = async ( // Ensure directory exists fs.ensureDirSync(path.dirname(thumbnailAbsolutePath)); - // Generate thumbnail - await new Promise((resolve, reject) => { - // -y to overwrite existing file - exec( - `ffmpeg -i "${videoFilePath}" -ss 00:00:00 -vframes 1 "${thumbnailAbsolutePath}" -y`, - (error) => { - if (error) { - logger.error("Error generating thumbnail:", error); - reject(error); - } else { - resolve(); - } - } - ); - }); + // Validate paths to prevent path traversal + const validatedVideoPath = validateVideoPath(videoFilePath); + const validatedThumbnailPath = validateImagePath(thumbnailAbsolutePath); + + // Generate thumbnail using execFileSafe to prevent command injection + try { + await execFileSafe("ffmpeg", [ + "-i", validatedVideoPath, + "-ss", "00:00:00", + "-vframes", "1", + validatedThumbnailPath, + "-y" + ]); + } catch (error) { + logger.error("Error generating thumbnail:", error); + throw error; + } // Update video record if needed (switching from remote to local, or creating new) if (needsDbUpdate) { diff --git a/backend/src/server.ts b/backend/src/server.ts index a4b49b4..8baa964 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,12 +7,12 @@ import cors from "cors"; import express from "express"; import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "./config/paths"; import { runMigrations } from "./db/migrate"; +import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware"; +import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware"; import apiRoutes from "./routes/api"; import settingsRoutes from "./routes/settingsRoutes"; import downloadManager from "./services/downloadManager"; import * as storageService from "./services/storageService"; -import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware"; -import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware"; import { logger } from "./utils/logger"; import { VERSION } from "./version"; @@ -22,10 +22,14 @@ VERSION.displayVersion(); const app = express(); const PORT = process.env.PORT || 5551; +// Security: Disable X-Powered-By header to prevent information disclosure +app.disable("x-powered-by"); + // Middleware app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Increase body size limits for large file uploads (10GB) +app.use(express.json({ limit: "100gb" })); +app.use(express.urlencoded({ extended: true, limit: "100gb" })); // Initialize storage (create directories, etc.) // storageService.initializeStorage(); // Moved inside startServer diff --git a/backend/src/services/cloudStorage/cloudScanner.ts b/backend/src/services/cloudStorage/cloudScanner.ts index a287053..028fbe7 100644 --- a/backend/src/services/cloudStorage/cloudScanner.ts +++ b/backend/src/services/cloudStorage/cloudScanner.ts @@ -2,12 +2,12 @@ * Cloud storage scanning operations */ -import { exec } from "child_process"; import fs from "fs-extra"; import path from "path"; import { IMAGES_DIR } from "../../config/paths"; import { formatVideoFilename } from "../../utils/helpers"; import { logger } from "../../utils/logger"; +import { execFileSafe, validateImagePath, validateUrl } from "../../utils/security"; import { getVideos, saveVideo } from "../storageService"; import { clearFileListCache, getFilesRecursively } from "./fileLister"; import { uploadFile } from "./fileUploader"; @@ -238,25 +238,27 @@ export async function scanCloudFiles( // Ensure directory exists fs.ensureDirSync(path.dirname(tempThumbnailPath)); + // Validate paths and URL to prevent command injection and SSRF + const validatedThumbnailPath = validateImagePath(tempThumbnailPath); + const validatedVideoUrl = validateUrl(videoSignedUrl); + // Generate thumbnail using ffmpeg with signed URL // ffmpeg can work with HTTP URLs directly - await new Promise((resolve, reject) => { - exec( - `ffmpeg -i "${videoSignedUrl}" -ss 00:00:00 -vframes 1 "${tempThumbnailPath}" -y`, - { timeout: 30000 }, // 30 second timeout - (error) => { - if (error) { - logger.error( - `[CloudStorage] Error generating thumbnail for ${filename}:`, - error - ); - reject(error); - } else { - resolve(); - } - } + try { + await execFileSafe("ffmpeg", [ + "-i", validatedVideoUrl, + "-ss", "00:00:00", + "-vframes", "1", + validatedThumbnailPath, + "-y" + ], { timeout: 30000 }); + } catch (error) { + logger.error( + `[CloudStorage] Error generating thumbnail for ${filename}:`, + error ); - }); + throw error; + } // Upload thumbnail to cloud storage (with correct filename and location) // remoteThumbnailPath is a full absolute path (e.g., /a/电影/video/thumbnail.jpg) @@ -271,21 +273,17 @@ export async function scanCloudFiles( // Get duration let duration: string | undefined = undefined; try { - const durationOutput = await new Promise( - (resolve, reject) => { - exec( - `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoSignedUrl}"`, - { timeout: 10000 }, - (error, stdout, _stderr) => { - if (error) { - reject(error); - } else { - resolve(stdout.trim()); - } - } - ); - } - ); + // Validate URL to prevent SSRF + const validatedVideoUrlForDuration = validateUrl(videoSignedUrl); + + const { stdout } = await execFileSafe("ffprobe", [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + validatedVideoUrlForDuration + ], { timeout: 10000 }); + + const durationOutput = stdout.trim(); if (durationOutput) { const durationSec = parseFloat(durationOutput); if (!isNaN(durationSec)) { diff --git a/backend/src/services/downloaders/ytdlp/ytdlpMetadata.ts b/backend/src/services/downloaders/ytdlp/ytdlpMetadata.ts index f901c58..f6e79f2 100644 --- a/backend/src/services/downloaders/ytdlp/ytdlpMetadata.ts +++ b/backend/src/services/downloaders/ytdlp/ytdlpMetadata.ts @@ -18,16 +18,27 @@ export async function getVideoInfo(url: string): Promise { const networkConfig = getNetworkConfigFromUserConfig(userConfig); const PROVIDER_SCRIPT = getProviderScript(); - const info = await executeYtDlpJson(url, { - ...networkConfig, - noWarnings: true, - preferFreeFormats: true, - ...(PROVIDER_SCRIPT - ? { - extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`, - } - : {}), - }); + // Explicitly exclude format-related options when fetching metadata + // Format restrictions should only apply during actual downloads, not metadata fetching + const info = await executeYtDlpJson( + url, + { + ...networkConfig, + noWarnings: true, + preferFreeFormats: true, + ...(PROVIDER_SCRIPT + ? { + extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`, + } + : {}), + // Explicitly exclude format options to avoid format availability errors + formatSort: undefined, + format: undefined, + S: undefined, + f: undefined, + }, + true // Enable retry without format restrictions if format error occurs + ); return { title: info.title || "Video", diff --git a/backend/src/services/metadataService.ts b/backend/src/services/metadataService.ts index 8a4540f..2ab53c9 100644 --- a/backend/src/services/metadataService.ts +++ b/backend/src/services/metadataService.ts @@ -1,4 +1,3 @@ -import { exec } from 'child_process'; import { eq } from 'drizzle-orm'; import fs from 'fs-extra'; import path from 'path'; @@ -9,6 +8,7 @@ import { import { VIDEOS_DIR } from '../config/paths'; import { db } from '../db'; import { videos } from '../db/schema'; +import { execFileSafe, validateVideoPath } from '../utils/security'; export const getVideoDuration = async (filePath: string): Promise => { try { @@ -17,17 +17,18 @@ export const getVideoDuration = async (filePath: string): Promise throw FileError.notFound(filePath); } - const command = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`; - const duration = await new Promise((resolve, reject) => { - exec(command, (error, stdout, stderr) => { - if (error) { - reject(ExecutionError.fromCommand(command, error, error.code || undefined)); - } else { - resolve(stdout.trim()); - } - }); - }); + // Validate path to prevent path traversal + const validatedPath = validateVideoPath(filePath); + // Use execFileSafe to prevent command injection + const { stdout } = await execFileSafe("ffprobe", [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + validatedPath + ]); + + const duration = stdout.trim(); if (duration) { const durationSec = parseFloat(duration); if (!isNaN(durationSec)) { diff --git a/backend/src/utils/helpers.ts b/backend/src/utils/helpers.ts index c4c711b..673959e 100644 --- a/backend/src/utils/helpers.ts +++ b/backend/src/utils/helpers.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { validateUrl } from "./security"; // Helper function to check if a string is a valid URL export function isValidUrl(string: string): boolean { @@ -38,20 +39,29 @@ export async function resolveShortUrl(url: string): Promise { try { console.log(`Resolving shortened URL: ${url}`); + // Validate URL to prevent SSRF attacks + const validatedUrl = validateUrl(url); + // Make a HEAD request to follow redirects - const response = await axios.head(url, { + const response = await axios.head(validatedUrl, { maxRedirects: 5, validateStatus: null, }); - // Get the final URL after redirects - const resolvedUrl = response.request.res.responseUrl || url; - console.log(`Resolved to: ${resolvedUrl}`); + // Get the final URL after redirects and validate it + const resolvedUrl = response.request.res.responseUrl || validatedUrl; + const validatedResolvedUrl = validateUrl(resolvedUrl); + console.log(`Resolved to: ${validatedResolvedUrl}`); - return resolvedUrl; + return validatedResolvedUrl; } catch (error: any) { console.error(`Error resolving shortened URL: ${error.message}`); - return url; // Return original URL if resolution fails + // If validation fails, return original URL only if it's already validated + try { + return validateUrl(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } } } diff --git a/backend/src/utils/security.ts b/backend/src/utils/security.ts new file mode 100644 index 0000000..5635139 --- /dev/null +++ b/backend/src/utils/security.ts @@ -0,0 +1,147 @@ +import { execFile } from "child_process"; +import path from "path"; +import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths"; + +/** + * Validates that a file path is within an allowed directory + * Prevents path traversal attacks + */ +export function validatePathWithinDirectory( + filePath: string, + allowedDir: string +): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedAllowedDir = path.resolve(allowedDir); + + // Ensure the resolved path starts with the allowed directory + return ( + resolvedPath.startsWith(resolvedAllowedDir + path.sep) || + resolvedPath === resolvedAllowedDir + ); +} + +/** + * Safely resolves a file path within an allowed directory + * Throws an error if the path is outside the allowed directory + */ +export function resolveSafePath(filePath: string, allowedDir: string): string { + const resolvedPath = path.resolve(filePath); + const resolvedAllowedDir = path.resolve(allowedDir); + + if ( + !resolvedPath.startsWith(resolvedAllowedDir + path.sep) && + resolvedPath !== resolvedAllowedDir + ) { + throw new Error( + `Path traversal detected: ${filePath} is outside ${allowedDir}` + ); + } + + return resolvedPath; +} + +/** + * Validates that a file path is within the videos directory + */ +export function validateVideoPath(filePath: string): string { + return resolveSafePath(filePath, VIDEOS_DIR); +} + +/** + * Validates that a file path is within the images directory + */ +export function validateImagePath(filePath: string): string { + return resolveSafePath(filePath, IMAGES_DIR); +} + +/** + * Safely execute a command with arguments + * Prevents command injection by using execFile instead of exec + */ +export function execFileSafe( + command: string, + args: string[], + options?: { cwd?: string; timeout?: number } +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, options, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout: stdout.toString(), stderr: stderr.toString() }); + } + }); + }); +} + +/** + * Validates a URL to prevent SSRF attacks + * Only allows http/https protocols and validates the hostname + */ +export function validateUrl(url: string): string { + try { + const urlObj = new URL(url); + + // Only allow http and https protocols + if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") { + throw new Error( + `Invalid protocol: ${urlObj.protocol}. Only http and https are allowed.` + ); + } + + // Block private/internal IP addresses + const hostname = urlObj.hostname; + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + hostname === "::1" || + hostname.startsWith("192.168.") || + hostname.startsWith("10.") || + hostname.startsWith("172.16.") || + hostname.startsWith("172.17.") || + hostname.startsWith("172.18.") || + hostname.startsWith("172.19.") || + hostname.startsWith("172.20.") || + hostname.startsWith("172.21.") || + hostname.startsWith("172.22.") || + hostname.startsWith("172.23.") || + hostname.startsWith("172.24.") || + hostname.startsWith("172.25.") || + hostname.startsWith("172.26.") || + hostname.startsWith("172.27.") || + hostname.startsWith("172.28.") || + hostname.startsWith("172.29.") || + hostname.startsWith("172.30.") || + hostname.startsWith("172.31.") || + hostname === "[::1]" + ) { + throw new Error( + `SSRF protection: Blocked access to private/internal IP: ${hostname}` + ); + } + + return url; + } catch (error) { + if (error instanceof TypeError) { + throw new Error(`Invalid URL format: ${url}`); + } + throw error; + } +} + +/** + * Sanitizes a string for safe use in HTML + * Prevents XSS attacks + */ +export function sanitizeHtml(str: string): string { + const map: { [key: string]: string } = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", + }; + return str.replace(/[&<>"'/]/g, (s) => map[s]); +} diff --git a/backend/src/utils/ytDlpUtils.ts b/backend/src/utils/ytDlpUtils.ts index da12e7f..d7ced2c 100644 --- a/backend/src/utils/ytDlpUtils.ts +++ b/backend/src/utils/ytDlpUtils.ts @@ -106,10 +106,14 @@ export function flagsToArgs(flags: Record): string[] { /** * Execute yt-dlp with JSON output and return parsed result + * @param url - Video URL + * @param flags - yt-dlp flags + * @param retryWithoutFormatRestrictions - If true, retry without format restrictions if format error occurs */ export async function executeYtDlpJson( url: string, - flags: Record = {} + flags: Record = {}, + retryWithoutFormatRestrictions: boolean = true ): Promise { const args = ["--dump-single-json", "--no-warnings", ...flagsToArgs(flags)]; @@ -144,8 +148,77 @@ export async function executeYtDlpJson( stderr += data.toString(); }); - subprocess.on("close", (code) => { + subprocess.on("close", async (code) => { if (code !== 0) { + // Check if this is a format availability error + const isFormatError = + stderr.includes("Requested format is not available") || + stderr.includes("format is not available") || + stderr.includes("No video formats found"); + + // If it's a format error and we should retry, try again without format restrictions + if (isFormatError && retryWithoutFormatRestrictions) { + const hasFormatRestrictions = + (flags.formatSort !== undefined && flags.formatSort !== null) || + (flags.format !== undefined && flags.format !== null) || + (flags.S !== undefined && flags.S !== null) || + (flags.f !== undefined && flags.f !== null); + + if (hasFormatRestrictions) { + console.log( + "Format not available, retrying without format restrictions..." + ); + try { + // Remove format-related flags + const retryFlags = { ...flags }; + delete retryFlags.formatSort; + delete retryFlags.format; + delete retryFlags.S; + delete retryFlags.f; + // Retry without format restrictions (don't retry again to avoid infinite loop) + const result = await executeYtDlpJson(url, retryFlags, false); + resolve(result); + return; + } catch (retryError) { + // If retry also fails, reject with original error + const error = new Error( + `yt-dlp process exited with code ${code}` + ); + (error as any).stderr = stderr; + reject(error); + return; + } + } else { + // Format error but no format restrictions in flags - might be from config file + // Try with explicit format override to bypass config file + console.log( + "Format not available (possibly from config file), retrying with explicit format override..." + ); + try { + const retryFlags = { + ...flags, + // Explicitly set format to "best" to override any config file settings + format: "best", + formatSort: undefined, + S: undefined, + f: undefined, + }; + // Retry without format restrictions (don't retry again to avoid infinite loop) + const result = await executeYtDlpJson(url, retryFlags, false); + resolve(result); + return; + } catch (retryError) { + // If retry also fails, reject with original error + const error = new Error( + `yt-dlp process exited with code ${code}` + ); + (error as any).stderr = stderr; + reject(error); + return; + } + } + } + const error = new Error(`yt-dlp process exited with code ${code}`); (error as any).stderr = stderr; reject(error); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 67b2036..cb8b933 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,6 +1,9 @@ server { listen 5556; + # Allow large file uploads (10GB limit for video files) + client_max_body_size 100G; + location / { root /usr/share/nginx/html; index index.html index.htm; @@ -13,6 +16,10 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Increase timeouts for large file uploads + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; } location ^~ /videos { diff --git a/frontend/src/components/Header/SearchInput.tsx b/frontend/src/components/Header/SearchInput.tsx index 3260905..4237c7a 100644 --- a/frontend/src/components/Header/SearchInput.tsx +++ b/frontend/src/components/Header/SearchInput.tsx @@ -73,7 +73,7 @@ const SearchInput: React.FC = ({ }} slotProps={{ input: { - startAdornment: ( + startAdornment: !isMobile ? ( = ({ - ), + ) : null, endAdornment: ( {isSearchMode && searchTerm && ( diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx index 308a3ff..5495358 100644 --- a/frontend/src/pages/VideoPlayer.tsx +++ b/frontend/src/pages/VideoPlayer.tsx @@ -24,6 +24,7 @@ import { useVideo } from '../contexts/VideoContext'; import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl'; import { Collection, Video } from '../types'; import { getRecommendations } from '../utils/recommendations'; +import { validateUrlForOpen } from '../utils/urlValidation'; const API_URL = import.meta.env.VITE_API_URL; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; @@ -205,9 +206,13 @@ const VideoPlayer: React.FC = () => { // If it's a YouTube or Bilibili video, try to get the channel URL if (video.source === 'youtube' || video.source === 'bilibili') { if (authorChannelUrl) { - // Open the channel URL in a new tab - window.open(authorChannelUrl, '_blank', 'noopener,noreferrer'); - return; + // Validate URL to prevent open redirect attacks + const validatedUrl = validateUrlForOpen(authorChannelUrl); + if (validatedUrl) { + // Open the channel URL in a new tab + window.open(validatedUrl, '_blank', 'noopener,noreferrer'); + return; + } } } diff --git a/frontend/src/utils/urlValidation.ts b/frontend/src/utils/urlValidation.ts new file mode 100644 index 0000000..3ec6e2d --- /dev/null +++ b/frontend/src/utils/urlValidation.ts @@ -0,0 +1,30 @@ +/** + * Validates a URL to prevent open redirect attacks + * Only allows http/https protocols + */ +export function isValidUrl(url: string): boolean { + try { + const urlObj = new URL(url); + // Only allow http and https protocols + return urlObj.protocol === "http:" || urlObj.protocol === "https:"; + } catch { + return false; + } +} + +/** + * Validates and sanitizes a URL for safe use in window.open + * Returns null if URL is invalid + */ +export function validateUrlForOpen( + url: string | null | undefined +): string | null { + if (!url) return null; + + if (!isValidUrl(url)) { + console.warn(`Invalid URL blocked: ${url}`); + return null; + } + + return url; +}