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:
Todd
2026-03-30 08:25:22 -04:00
parent 523f91788e
commit 49e72207bf
24 changed files with 295 additions and 65 deletions

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

View File

@@ -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'}

View File

@@ -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'],

View File

@@ -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],

View File

@@ -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'],

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}