fix(security): block path traversal in file/thumb endpoints
- Add _safe_resolve helper validating that resolved path stays within DOWNLOADS_DIR - Apply to get_media_file and get_video_thumbnail - Reject null bytes and overlong filenames - Replace bare except in magic-byte fallback with OSError + logging
This commit is contained in:
parent
986f1dfef4
commit
0402aeb06e
2 changed files with 50 additions and 17 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"""Media browsing, serving, and cleanup API routes."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -10,9 +11,24 @@ from ...database import Database
|
|||
from .. import config_manager
|
||||
from ..deps import DOWNLOADS_DIR, THUMBS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
|
||||
def _safe_resolve(filename: str, base: Path) -> Path:
|
||||
"""Resolve a user-supplied filename inside base, rejecting traversal attempts."""
|
||||
if not filename or "\x00" in filename or len(filename) > 255:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
candidate = (base / filename).resolve()
|
||||
base_resolved = base.resolve()
|
||||
try:
|
||||
candidate.relative_to(base_resolved)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename") from e
|
||||
return candidate
|
||||
|
||||
|
||||
@router.get("/api/media")
|
||||
async def get_media_files(
|
||||
limit: int = Query(default=50, le=200),
|
||||
|
|
@ -72,8 +88,8 @@ async def get_media_authors():
|
|||
@router.get("/api/media/file/{filename:path}")
|
||||
async def get_media_file(filename: str, range: str | None = Header(None)):
|
||||
"""Serve a media file with Range request support for video streaming."""
|
||||
file_path = DOWNLOADS_DIR / filename
|
||||
if not file_path.exists():
|
||||
file_path = _safe_resolve(filename, DOWNLOADS_DIR)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
|
|
@ -170,9 +186,9 @@ def _generate_thumbnail(video_path: Path) -> Path | None:
|
|||
@router.get("/api/media/thumb/{filename:path}")
|
||||
async def get_video_thumbnail(filename: str):
|
||||
"""Get or generate a thumbnail for a video file."""
|
||||
video_path = DOWNLOADS_DIR / filename
|
||||
video_path = _safe_resolve(filename, DOWNLOADS_DIR)
|
||||
|
||||
if not video_path.exists():
|
||||
if not video_path.exists() or not video_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
video_extensions = {".mp4", ".webm", ".mov", ".avi", ".mkv"}
|
||||
|
|
@ -189,7 +205,7 @@ async def get_video_thumbnail(filename: str):
|
|||
if thumb_path and thumb_path.exists():
|
||||
return FileResponse(thumb_path, media_type="image/jpeg")
|
||||
|
||||
# Check magic bytes as fallback
|
||||
# Check magic bytes as fallback (some "videos" are actually misnamed images)
|
||||
try:
|
||||
with open(video_path, "rb") as f:
|
||||
header = f.read(12)
|
||||
|
|
@ -197,8 +213,8 @@ async def get_video_thumbnail(filename: str):
|
|||
return FileResponse(video_path, media_type="image/jpeg")
|
||||
if header[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return FileResponse(video_path, media_type="image/png")
|
||||
except Exception:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("Magic-byte thumbnail fallback failed for %s: %s", video_path, e)
|
||||
|
||||
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
|
||||
|
||||
|
|
|
|||
|
|
@ -1715,6 +1715,17 @@
|
|||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab, skipLoad = false) {
|
||||
// Disconnect any previously-active intersection observers so they don't
|
||||
// keep firing fetches against the tab we're leaving.
|
||||
if (typeof galleryObserver !== 'undefined' && galleryObserver) {
|
||||
galleryObserver.disconnect();
|
||||
galleryObserver = null;
|
||||
}
|
||||
if (typeof authorsObserver !== 'undefined' && authorsObserver) {
|
||||
authorsObserver.disconnect();
|
||||
authorsObserver = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
|
|
@ -2210,7 +2221,7 @@
|
|||
btn.disabled = false;
|
||||
btn.textContent = 'Limpar Midias de Blacklistados';
|
||||
|
||||
if (preview.count === 0) {
|
||||
if (preview.total_count === 0) {
|
||||
showAlert('Nenhuma midia de autores blacklistados encontrada.', 'success');
|
||||
return;
|
||||
}
|
||||
|
|
@ -2219,7 +2230,7 @@
|
|||
const moreAuthors = preview.authors.length > 5 ? ` e mais ${preview.authors.length - 5}` : '';
|
||||
|
||||
const confirmed = confirm(
|
||||
`Encontradas ${preview.count} midias de autores blacklistados:\n\n` +
|
||||
`Encontradas ${preview.total_count} midias de autores blacklistados:\n\n` +
|
||||
`Autores: ${authorsList}${moreAuthors}\n\n` +
|
||||
`Deseja deletar TODAS essas midias permanentemente?\n\n` +
|
||||
`Esta acao NAO pode ser desfeita!`
|
||||
|
|
@ -2359,12 +2370,12 @@
|
|||
|
||||
async function loadGallery(append = false) {
|
||||
if (isLoadingGallery || (!hasMoreItems && append)) return;
|
||||
isLoadingGallery = true;
|
||||
|
||||
if (!append) {
|
||||
resetGallery();
|
||||
}
|
||||
|
||||
isLoadingGallery = true;
|
||||
const loader = document.getElementById('gallery-loader');
|
||||
loader.classList.remove('hidden');
|
||||
|
||||
|
|
@ -2382,6 +2393,7 @@
|
|||
if (favoritesFilter === 'favorites') url += `&favorite_authors=true`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
totalItems = data.total;
|
||||
|
|
@ -2411,6 +2423,7 @@
|
|||
} catch (err) {
|
||||
console.error('Failed to load gallery:', err);
|
||||
loader.classList.add('hidden');
|
||||
showAlert('Falha ao carregar galeria: ' + (err.message || err), 'error');
|
||||
} finally {
|
||||
isLoadingGallery = false;
|
||||
}
|
||||
|
|
@ -2826,8 +2839,9 @@
|
|||
async function deleteMedia(postId) {
|
||||
if (!confirm('Deletar este arquivo permanentemente?')) return;
|
||||
|
||||
// Save author info and current index before deleting
|
||||
// Capture state of the item being deleted BEFORE we mutate currentItem
|
||||
const author = currentItem ? currentItem.author : null;
|
||||
const deletedMediaType = currentItem ? currentItem.media_type : null;
|
||||
const deletedIndex = currentItemIndex;
|
||||
|
||||
try {
|
||||
|
|
@ -2864,8 +2878,7 @@
|
|||
loadRecentDownloads();
|
||||
|
||||
// Suggest adding author to blacklist (skip for videos)
|
||||
const mediaType = currentItem ? currentItem.media_type : null;
|
||||
const isVideo = mediaType === 'video' || mediaType === 'gif';
|
||||
const isVideo = deletedMediaType === 'video' || deletedMediaType === 'gif';
|
||||
if (!isVideo && author && author !== 'deleted' && author !== '[deleted]') {
|
||||
const addToBlacklist = confirm(`Deseja adicionar u/${author} na blacklist?\n\nIsso impedira que novas midias deste usuario sejam baixadas.`);
|
||||
if (addToBlacklist) {
|
||||
|
|
@ -2873,8 +2886,12 @@
|
|||
}
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showAlert(data.detail || 'Erro ao deletar', 'error');
|
||||
let detail = 'Erro ao deletar';
|
||||
try {
|
||||
const data = await response.json();
|
||||
detail = data.detail || detail;
|
||||
} catch { /* response is not JSON */ }
|
||||
showAlert(detail, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Erro de conexao', 'error');
|
||||
|
|
@ -3350,12 +3367,12 @@
|
|||
|
||||
async function loadAuthors(append = false) {
|
||||
if (isLoadingAuthors || (!authorsHasMore && append)) return;
|
||||
isLoadingAuthors = true;
|
||||
|
||||
if (!append) {
|
||||
resetAuthors();
|
||||
}
|
||||
|
||||
isLoadingAuthors = true;
|
||||
const loader = document.getElementById('authors-loader');
|
||||
loader.classList.remove('hidden');
|
||||
|
||||
|
|
@ -3463,6 +3480,7 @@
|
|||
|
||||
async function loadAuthorMedia(append = false) {
|
||||
if (!currentAuthor || isLoadingAuthorMedia || (!authorMediaHasMore && append)) return;
|
||||
isLoadingAuthorMedia = true;
|
||||
|
||||
if (!append) {
|
||||
authorMediaOffset = 0;
|
||||
|
|
@ -3471,7 +3489,6 @@
|
|||
document.getElementById('author-media-grid').innerHTML = '';
|
||||
}
|
||||
|
||||
isLoadingAuthorMedia = true;
|
||||
const loader = document.getElementById('author-media-loader');
|
||||
loader.classList.remove('hidden');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue