Encrypt file paths in API URLs using Fernet tokens
Raw filesystem paths were exposed in browser URLs, dev tools, and proxy logs. Now all file-serving endpoints accept an opaque encrypted token (t= param) derived from the session secret via HKDF, with a 4-hour TTL. Backend: - Add core/path_tokens.py with Fernet encrypt/decrypt (HKDF from .session_secret) - Add file_token to all list/gallery/feed/search responses across 7 routers - Accept optional t= param on all file-serving endpoints (backward compatible) Frontend: - Update 4 URL helpers in api.ts to prefer token when available - Add 4 new helpers for paid-content/embedded-metadata URLs - Update all 14 page/component files to pass file_token to URL builders - Add file_token to all relevant TypeScript interfaces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
57
web/backend/core/path_tokens.py
Normal file
57
web/backend/core/path_tokens.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Path Token Encryption
|
||||
|
||||
Encrypts file paths into opaque, time-limited Fernet tokens so raw filesystem
|
||||
paths are never exposed in API URLs.
|
||||
|
||||
encode_path("/opt/immich/paid/creator/file.mp4")
|
||||
# => "gAAAAABn..." (URL-safe, 4-hour TTL)
|
||||
|
||||
decode_path("gAAAAABn...")
|
||||
# => "/opt/immich/paid/creator/file.mp4"
|
||||
"""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""Derive a Fernet key from .session_secret via HKDF (cached after first call)."""
|
||||
if not hasattr(_get_fernet, "_instance"):
|
||||
from ..core.config import settings
|
||||
secret_path = settings.PROJECT_ROOT / ".session_secret"
|
||||
secret = secret_path.read_text().strip().encode()
|
||||
|
||||
derived = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b"path-token-v1",
|
||||
info=b"fernet-key",
|
||||
).derive(secret)
|
||||
|
||||
key = base64.urlsafe_b64encode(derived)
|
||||
_get_fernet._instance = Fernet(key)
|
||||
return _get_fernet._instance
|
||||
|
||||
|
||||
def encode_path(file_path: str) -> str:
|
||||
"""Encrypt a file path into a URL-safe Fernet token (4-hour TTL)."""
|
||||
f = _get_fernet()
|
||||
return f.encrypt(file_path.encode()).decode()
|
||||
|
||||
|
||||
def decode_path(token: str) -> str:
|
||||
"""Decrypt a Fernet token back to a file path.
|
||||
|
||||
Raises HTTP 400 on invalid or expired tokens (TTL = 4 hours).
|
||||
"""
|
||||
f = _get_fernet()
|
||||
try:
|
||||
return f.decrypt(token.encode(), ttl=14400).decode()
|
||||
except InvalidToken:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired file token")
|
||||
@@ -10,6 +10,7 @@ from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from ..core.dependencies import get_current_user, get_app_state
|
||||
from ..core.exceptions import handle_exceptions
|
||||
from ..core.path_tokens import encode_path
|
||||
from modules.universal_logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
@@ -76,9 +77,11 @@ async def get_recent_items(
|
||||
|
||||
media_items = []
|
||||
for row in cursor.fetchall():
|
||||
fp = row[1]
|
||||
media_items.append({
|
||||
'id': row[0],
|
||||
'file_path': row[1],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'filename': row[2],
|
||||
'source': row[3],
|
||||
'platform': row[4],
|
||||
@@ -152,9 +155,11 @@ async def get_recent_items(
|
||||
'matched_person': row[13]
|
||||
}
|
||||
|
||||
fp = row[1]
|
||||
review_items.append({
|
||||
'id': row[0],
|
||||
'file_path': row[1],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'filename': row[2],
|
||||
'source': row[3],
|
||||
'platform': row[4],
|
||||
@@ -300,5 +305,6 @@ async def set_dismissed_cards(
|
||||
preference_value = excluded.preference_value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (user_id, json.dumps(data)))
|
||||
conn.commit()
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -20,6 +20,7 @@ from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, get_app_state
|
||||
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError
|
||||
from ..core.path_tokens import encode_path
|
||||
from ..core.responses import message_response, id_response, count_response, offset_paginated
|
||||
from modules.discovery_system import get_discovery_system
|
||||
from modules.universal_logger import get_logger
|
||||
@@ -381,8 +382,10 @@ async def get_smart_folders_stats(
|
||||
|
||||
previews = []
|
||||
for row in cursor.fetchall():
|
||||
fp = row['file_path']
|
||||
previews.append({
|
||||
'file_path': row['file_path'],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'content_type': row['content_type']
|
||||
})
|
||||
|
||||
@@ -758,9 +761,11 @@ async def get_recent_activity(
|
||||
''', (limit,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
fp = row['file_path']
|
||||
activity['recent_downloads'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['file_path'],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'filename': row['filename'],
|
||||
'platform': row['platform'],
|
||||
'source': row['source'],
|
||||
@@ -788,9 +793,11 @@ async def get_recent_activity(
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
fp = row['recycle_path']
|
||||
activity['recent_deleted'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['recycle_path'],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'original_path': row['original_path'],
|
||||
'filename': row['original_filename'],
|
||||
'platform': metadata.get('platform', 'unknown'),
|
||||
@@ -814,9 +821,11 @@ async def get_recent_activity(
|
||||
''', (limit,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
fp = row['file_path']
|
||||
activity['recent_moved_to_review'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['file_path'],
|
||||
'file_path': fp,
|
||||
'file_token': encode_path(fp) if fp else None,
|
||||
'filename': row['filename'],
|
||||
'platform': row['platform'],
|
||||
'source': row['source'],
|
||||
|
||||
@@ -18,6 +18,7 @@ from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, get_app_state, require_admin
|
||||
from ..core.path_tokens import encode_path
|
||||
from ..core.exceptions import (
|
||||
handle_exceptions,
|
||||
DatabaseError,
|
||||
@@ -654,6 +655,7 @@ async def advanced_search_downloads(
|
||||
"content_type": row[3],
|
||||
"filename": row[4],
|
||||
"file_path": row[5],
|
||||
"file_token": encode_path(row[5]) if row[5] else None,
|
||||
"file_size": row[6],
|
||||
"download_date": row[7],
|
||||
"post_date": row[8],
|
||||
|
||||
@@ -37,6 +37,7 @@ from ..core.exceptions import (
|
||||
ValidationError
|
||||
)
|
||||
from ..core.responses import now_iso8601
|
||||
from ..core.path_tokens import encode_path, decode_path
|
||||
from modules.universal_logger import get_logger
|
||||
from ..core.utils import (
|
||||
get_media_dimensions,
|
||||
@@ -177,6 +178,7 @@ async def get_media_thumbnail(
|
||||
file_path: str = None,
|
||||
media_type: str = None,
|
||||
token: str = None,
|
||||
t: str = None,
|
||||
current_user: Dict = Depends(get_current_user_media)
|
||||
):
|
||||
"""
|
||||
@@ -192,7 +194,10 @@ async def get_media_thumbnail(
|
||||
Args:
|
||||
file_path: Path to the media file
|
||||
media_type: 'image' or 'video'
|
||||
t: Encrypted file token (alternative to file_path)
|
||||
"""
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
resolved_path = validate_file_path(file_path)
|
||||
|
||||
app_state = get_app_state()
|
||||
@@ -261,11 +266,14 @@ async def get_media_thumbnail(
|
||||
@handle_exceptions
|
||||
async def get_media_preview(
|
||||
request: Request,
|
||||
file_path: str,
|
||||
file_path: str = None,
|
||||
token: str = None,
|
||||
t: str = None,
|
||||
current_user: Dict = Depends(get_current_user_media)
|
||||
):
|
||||
"""Serve a media file for preview."""
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
resolved_path = validate_file_path(file_path)
|
||||
|
||||
if not resolved_path.exists() or not resolved_path.is_file():
|
||||
@@ -283,12 +291,17 @@ async def get_media_preview(
|
||||
@handle_exceptions
|
||||
async def get_media_metadata(
|
||||
request: Request,
|
||||
file_path: str,
|
||||
file_path: str = None,
|
||||
t: str = None,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get cached metadata for a media file (resolution, duration, etc.).
|
||||
"""
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
elif not file_path:
|
||||
raise ValidationError("Either 't' or 'file_path' is required")
|
||||
resolved_path = validate_file_path(file_path)
|
||||
|
||||
if not resolved_path.exists() or not resolved_path.is_file():
|
||||
@@ -381,7 +394,8 @@ async def get_media_metadata(
|
||||
@handle_exceptions
|
||||
async def get_embedded_metadata(
|
||||
request: Request,
|
||||
file_path: str,
|
||||
file_path: str = None,
|
||||
t: str = None,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@@ -392,6 +406,8 @@ async def get_embedded_metadata(
|
||||
|
||||
This is different from /metadata which returns technical info (resolution, duration).
|
||||
"""
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
resolved_path = validate_file_path(file_path)
|
||||
|
||||
if not resolved_path.exists() or not resolved_path.is_file():
|
||||
@@ -1332,12 +1348,14 @@ async def get_media_gallery(
|
||||
'scan_date': row['face_scan_date'] if has_face_data else None
|
||||
}
|
||||
|
||||
fp = row['file_path']
|
||||
item = {
|
||||
"id": row['id'],
|
||||
"platform": row['platform'],
|
||||
"source": row['source'] or 'unknown',
|
||||
"filename": row['filename'],
|
||||
"file_path": row['file_path'],
|
||||
"file_path": fp,
|
||||
"file_token": encode_path(fp) if fp else None,
|
||||
"file_size": row['file_size'] or 0,
|
||||
"media_type": row['media_type'] or 'image',
|
||||
"download_date": row['download_date'],
|
||||
|
||||
@@ -20,7 +20,7 @@ from threading import Lock
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request, Response, UploadFile, File, Form
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from slowapi import Limiter
|
||||
@@ -28,6 +28,7 @@ from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, get_app_state
|
||||
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError
|
||||
from ..core.path_tokens import encode_path, decode_path
|
||||
from ..core.responses import message_response, now_iso8601
|
||||
from modules.universal_logger import get_logger
|
||||
|
||||
@@ -2719,6 +2720,12 @@ async def get_feed(
|
||||
total = db.get_posts_count(**filter_kwargs)
|
||||
total_media = db.get_media_count(**filter_kwargs)
|
||||
|
||||
# Add encrypted file tokens to attachment local_paths
|
||||
for post in posts:
|
||||
for att in post.get('attachments', []):
|
||||
lp = att.get('local_path')
|
||||
att['file_token'] = encode_path(lp) if lp else None
|
||||
|
||||
return {
|
||||
"posts": posts,
|
||||
"count": len(posts),
|
||||
@@ -2743,6 +2750,11 @@ async def get_post(
|
||||
if not post:
|
||||
raise NotFoundError(f"Post {post_id} not found")
|
||||
|
||||
# Add encrypted file tokens to attachment local_paths
|
||||
for att in post.get('attachments', []):
|
||||
lp = att.get('local_path')
|
||||
att['file_token'] = encode_path(lp) if lp else None
|
||||
|
||||
# Mark as viewed
|
||||
db.mark_post_viewed(post_id)
|
||||
|
||||
@@ -3194,6 +3206,11 @@ async def get_notifications(
|
||||
db = _get_db_adapter()
|
||||
notifications = db.get_notifications(unread_only=unread_only, limit=limit, offset=offset)
|
||||
unread_count = db.get_unread_notification_count()
|
||||
# Add encrypted file tokens to media_files in metadata
|
||||
for notif in notifications:
|
||||
for media in notif.get('metadata', {}).get('media_files', []):
|
||||
fp = media.get('file_path')
|
||||
media['file_token'] = encode_path(fp) if fp else None
|
||||
return {"notifications": notifications, "count": len(notifications), "unread_count": unread_count}
|
||||
|
||||
|
||||
@@ -4220,10 +4237,15 @@ async def _download_post_background(post_id: int):
|
||||
@handle_exceptions
|
||||
async def serve_file(
|
||||
request: Request,
|
||||
path: str,
|
||||
path: Optional[str] = None,
|
||||
t: Optional[str] = None,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a downloaded file with byte-range support for video streaming"""
|
||||
if t:
|
||||
path = decode_path(t)
|
||||
elif not path:
|
||||
raise ValidationError("Either 't' or 'path' is required")
|
||||
file_path = Path(path)
|
||||
|
||||
# Security: ensure path is within allowed directories
|
||||
@@ -4716,16 +4738,36 @@ async def backfill_thumbnails(
|
||||
@handle_exceptions
|
||||
async def get_thumbnail_by_path(
|
||||
request: Request,
|
||||
file_path: str = Query(..., description="Full path to the file"),
|
||||
file_path: Optional[str] = Query(None, description="Full path to the file"),
|
||||
t: Optional[str] = None,
|
||||
size: str = Query(default="small", regex="^(small|medium|large)$"),
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get thumbnail for a file by its path (for notifications page)"""
|
||||
from pathlib import Path
|
||||
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
elif not file_path:
|
||||
raise ValidationError("Either 't' or 'file_path' is required")
|
||||
path = Path(file_path)
|
||||
|
||||
# Security: ensure path is within allowed directories
|
||||
db = _get_db_adapter()
|
||||
config = db.get_config()
|
||||
base_path = Path(config.get('base_download_path', '/paid-content'))
|
||||
try:
|
||||
resolved_path = path.resolve()
|
||||
resolved_base = base_path.resolve()
|
||||
if not resolved_path.is_relative_to(resolved_base):
|
||||
raise ValidationError("Access denied: path outside allowed directory")
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ValidationError("Invalid path")
|
||||
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
raise NotFoundError("File not found")
|
||||
|
||||
# Determine file type from extension
|
||||
ext = path.suffix.lower()
|
||||
@@ -4734,7 +4776,7 @@ async def get_thumbnail_by_path(
|
||||
elif ext in ['.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v']:
|
||||
file_type = 'video'
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||
raise ValidationError("Unsupported file type")
|
||||
|
||||
# Size mapping
|
||||
size_map = {"small": (200, 200), "medium": (400, 400), "large": (800, 800)}
|
||||
@@ -4746,7 +4788,7 @@ async def get_thumbnail_by_path(
|
||||
await scraper.close()
|
||||
|
||||
if not thumbnail_data:
|
||||
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
|
||||
raise ValidationError("Failed to generate thumbnail")
|
||||
|
||||
return Response(
|
||||
content=thumbnail_data,
|
||||
@@ -4760,15 +4802,35 @@ async def get_thumbnail_by_path(
|
||||
@handle_exceptions
|
||||
async def get_preview_by_path(
|
||||
request: Request,
|
||||
file_path: str = Query(..., description="Full path to the file"),
|
||||
file_path: Optional[str] = Query(None, description="Full path to the file"),
|
||||
t: Optional[str] = None,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a file for preview (for notifications lightbox)"""
|
||||
from pathlib import Path
|
||||
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
elif not file_path:
|
||||
raise ValidationError("Either 't' or 'file_path' is required")
|
||||
path = Path(file_path)
|
||||
|
||||
# Security: ensure path is within allowed directories
|
||||
db = _get_db_adapter()
|
||||
config = db.get_config()
|
||||
base_path = Path(config.get('base_download_path', '/paid-content'))
|
||||
try:
|
||||
resolved_path = path.resolve()
|
||||
resolved_base = base_path.resolve()
|
||||
if not resolved_path.is_relative_to(resolved_base):
|
||||
raise ValidationError("Access denied: path outside allowed directory")
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ValidationError("Invalid path")
|
||||
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
raise NotFoundError("File not found")
|
||||
|
||||
# Determine media type from extension
|
||||
ext = path.suffix.lower()
|
||||
@@ -6221,6 +6283,10 @@ async def get_gallery_media(
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
# Add encrypted file tokens so raw paths aren't exposed in URLs
|
||||
for item in items:
|
||||
lp = item.get('local_path')
|
||||
item['file_token'] = encode_path(lp) if lp else None
|
||||
# Only run COUNT on first page — subsequent pages don't need it
|
||||
total = None
|
||||
if offset == 0:
|
||||
|
||||
@@ -36,6 +36,7 @@ from ..core.exceptions import (
|
||||
ValidationError
|
||||
)
|
||||
from ..core.responses import now_iso8601
|
||||
from ..core.path_tokens import encode_path, decode_path
|
||||
from modules.universal_logger import get_logger
|
||||
from modules.date_utils import DateHandler
|
||||
from ..core.utils import get_media_dimensions, get_media_dimensions_batch
|
||||
@@ -244,9 +245,11 @@ async def get_review_queue(
|
||||
else:
|
||||
width, height = dimensions_cache.get(row[1], (row[7], row[8]))
|
||||
|
||||
fp = row[1]
|
||||
file_item = {
|
||||
"filename": row[2],
|
||||
"file_path": row[1],
|
||||
"file_path": fp,
|
||||
"file_token": encode_path(fp) if fp else None,
|
||||
"file_size": row[6] if row[6] else 0,
|
||||
"added_date": row[10] if row[10] else '',
|
||||
"post_date": row[11] if row[11] else '',
|
||||
@@ -718,11 +721,14 @@ async def delete_review_file(
|
||||
@handle_exceptions
|
||||
async def get_review_file(
|
||||
request: Request,
|
||||
file_path: str,
|
||||
file_path: str = None,
|
||||
token: str = None,
|
||||
t: str = None,
|
||||
current_user: Dict = Depends(get_current_user_media)
|
||||
):
|
||||
"""Serve a file from the review queue."""
|
||||
if t:
|
||||
file_path = decode_path(t)
|
||||
requested_file = Path(file_path)
|
||||
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,7 @@ from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, require_admin, get_app_state
|
||||
from ..core.exceptions import handle_exceptions, ValidationError
|
||||
from ..core.path_tokens import encode_path
|
||||
from modules.semantic_search import get_semantic_search
|
||||
from modules.universal_logger import get_logger
|
||||
|
||||
@@ -93,6 +94,9 @@ async def semantic_search(
|
||||
source=source,
|
||||
threshold=threshold
|
||||
)
|
||||
for r in results:
|
||||
fp = r.get('file_path')
|
||||
r['file_token'] = encode_path(fp) if fp else None
|
||||
return {"results": results, "count": len(results), "query": query}
|
||||
|
||||
|
||||
@@ -118,6 +122,9 @@ async def find_similar_files(
|
||||
source=source,
|
||||
threshold=threshold
|
||||
)
|
||||
for r in results:
|
||||
fp = r.get('file_path')
|
||||
r['file_token'] = encode_path(fp) if fp else None
|
||||
return {"results": results, "count": len(results), "source_file_id": file_id}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user