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:
authentik Default Admin 2026-05-17 11:43:21 +01:00
parent 986f1dfef4
commit 0402aeb06e
2 changed files with 50 additions and 17 deletions

View file

@ -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")

View file

@ -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');