Skip to content
2 changes: 1 addition & 1 deletion fiftyone/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from importlib.metadata import metadata


CLIENT_TYPE = "fiftyone"

FIFTYONE_DIR = os.path.dirname(os.path.abspath(__file__))
Expand All @@ -30,6 +29,7 @@
TEAMS_PATH = os.path.join(FIFTYONE_CONFIG_DIR, "var", "teams.json")
WELCOME_PATH = os.path.join(FIFTYONE_CONFIG_DIR, "var", "welcome.json")
RESOURCES_DIR = os.path.join(FIFTYONE_DIR, "resources")
MEDIA_ALLOWED_ROOTS = os.environ.get("FIFTYONE_MEDIA_ALLOWED_ROOTS", "")

#
# The compatible versions for this client
Expand Down
16 changes: 13 additions & 3 deletions fiftyone/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
is_notification_service_disabled,
)
from fiftyone.server.constants import SCALAR_OVERRIDES
from fiftyone.server.media_cache import add_allowed_dir
from fiftyone.server.context import GraphQL
from fiftyone.server.extensions import EndSession
from fiftyone.server.mutation import Mutation
Expand Down Expand Up @@ -117,9 +118,9 @@ async def dispatch(
)

mtypes = ( # ensure mimetypes for Windows
('application/javascript', '.js'),
('text/css', '.css'),
('application/wasm', '.wasm'),
("application/javascript", ".js"),
("text/css", ".css"),
("application/wasm", ".wasm"),
)
for mtype, ext in mtypes:
mimetypes.add_type(mtype, ext)
Expand Down Expand Up @@ -171,6 +172,15 @@ async def dispatch(

@app.on_event("startup")
async def startup_event():
# Seed media directory allowlist for path traversal prevention
add_allowed_dir(fo.config.default_dataset_dir)
add_allowed_dir(fo.config.dataset_zoo_dir)
add_allowed_dir(fo.config.model_zoo_dir)

for root in foc.MEDIA_ALLOWED_ROOTS.split(","):
if root := root.strip():
add_allowed_dir(root)

if is_notification_service_disabled():
logger.info("Execution Store notification service is disabled")
return
Expand Down
58 changes: 58 additions & 0 deletions fiftyone/server/media_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
Allowed media directory registry for path traversal prevention.

| Copyright 2017-2026, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""

import os
import threading
from pathlib import Path

_lock = threading.Lock()
_allowed_dirs = set()


def add_allowed_dir(dir_path: str) -> None:
"""Registers a directory as allowed for media serving.

Args:
dir_path: a directory path
"""
resolved = str(Path(dir_path).expanduser().resolve())
with _lock:
_allowed_dirs.add(resolved)


def add_allowed_dir_for_filepath(filepath: str) -> None:
"""Registers the parent directory of a filepath as allowed.

Args:
filepath: a file path
"""
resolved_parent = str(Path(filepath).expanduser().resolve().parent)
with _lock:
_allowed_dirs.add(resolved_parent)


def is_path_allowed(resolved_path: str) -> bool:
"""Checks if a resolved path falls under any allowed directory.

Args:
resolved_path: an absolute resolved file path

Returns:
True/False
"""
with _lock:
return any(
resolved_path == d or resolved_path.startswith(d + os.sep)
for d in _allowed_dirs
)


def clear() -> None:
"""Clears all allowed directories."""
with _lock:
_allowed_dirs.clear()
8 changes: 7 additions & 1 deletion fiftyone/server/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from fiftyone.utils.rerun import RrdFile

import fiftyone.core.media as fom
from fiftyone.server.media_cache import add_allowed_dir_for_filepath

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -81,7 +82,11 @@ async def get_metadata(
opm_field,
detections_fields,
additional_fields,
) = additional_media_fields if additional_media_fields is not None else _get_additional_media_fields(collection)
) = (
additional_media_fields
if additional_media_fields is not None
else _get_additional_media_fields(collection)
)

filepath_result, filepath_source, urls = _create_media_urls(
collection,
Expand Down Expand Up @@ -452,6 +457,7 @@ def _create_media_urls(

if path not in cache:
cache[path] = path
add_allowed_dir_for_filepath(path)

if use_opm and opm_filepath == field:
filepath_source = path
Expand Down
94 changes: 92 additions & 2 deletions fiftyone/server/routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
|
"""

import mimetypes
import os
import typing as t
from pathlib import Path

import anyio
import aiofiles
import eta.core.image as etai
import eta.core.video as etav
from aiofiles.threadpool.binary import AsyncBufferedReader
from aiofiles.os import stat as aio_stat
from starlette.endpoints import HTTPEndpoint
Expand All @@ -22,6 +26,87 @@
guess_type,
)

from fiftyone.server.media_cache import is_path_allowed

# @todo: migrate to eta with proper is_*_mime_type() detection methods
for _mime, _ext in (
("application/x-pcd", ".pcd"),
("application/x-fo3d", ".fo3d"),
("application/x-rrd", ".rrd"),
("application/x-npy", ".npy"),
):
mimetypes.add_type(_mime, _ext)

# Non-media MIME types blocked on the /media endpoint only
_BLOCKED_MIME_PREFIXES = ("text/",)
_BLOCKED_MIME_TYPES = frozenset(
{
"application/json",
"application/xml",
"application/javascript",
}
)


def _is_media_file(filepath: str) -> bool:
"""Checks whether a filepath has a recognized media MIME type.

Uses ``eta.core`` for image/video detection, then falls back to
``mimetypes.guess_type``. Files with unrecognized extensions (None
MIME) are allowed through to the directory allowlist check.
"""
if etai.is_image_mime_type(filepath):
return True

if etav.is_video_mime_type(filepath):
return True

mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
return True

if mime_type.startswith(_BLOCKED_MIME_PREFIXES):
return False

return mime_type not in _BLOCKED_MIME_TYPES


def _validate_media_path(
request: Request,
) -> tuple[t.Optional[str], t.Optional[Response]]:
"""Validates and normalizes the requested media path.

Applies three layers of defense:

1. **Path normalization** — expands ``~``, resolves ``..`` and
symlinks via ``pathlib.Path.expanduser().resolve()``.
2. **Media type check** — rejects files with known non-media MIME
types (``text/*``, ``application/json``, etc.) on this endpoint.
3. **Directory allowlist** — rejects files outside directories that
have been registered by dataset media resolution or server config.

Args:
request: a Starlette ``Request``

Returns:
a ``(resolved_path, None)`` tuple on success, or
``(None, error_response)`` on failure
"""
raw_path = request.query_params.get("filepath")
if not raw_path:
return None, Response(status_code=400)

resolved = Path(raw_path).expanduser().resolve(strict=False)
resolved_str = str(resolved)

if not _is_media_file(resolved_str):
return None, Response(status_code=403)

if not is_path_allowed(resolved_str):
return None, Response(status_code=403)

return resolved_str, None


async def ranged(
file: AsyncBufferedReader,
Expand Down Expand Up @@ -58,7 +143,9 @@ class Media(HTTPEndpoint):
async def get(
self, request: Request
) -> t.Union[FileResponse, StreamingResponse]:
path = request.query_params["filepath"]
path, error = _validate_media_path(request)
if error:
return error

response: t.Union[FileResponse, StreamingResponse]

Expand Down Expand Up @@ -136,7 +223,10 @@ async def ranged_file_response(
return response

async def head(self, request: Request) -> Response:
path = request.query_params["filepath"]
path, error = _validate_media_path(request)
if error:
return error

response = Response()
size = (await aio_stat(path)).st_size
response.headers.update(
Expand Down
Loading
Loading