Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions burr/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,9 @@ def _run_server(
@click.option(
"--host",
default="127.0.0.1",
help="Host to run the server on -- use 0.0.0.0 if you want "
"to expose it to the network (E.G. in a docker image)",
help="Host to run the server on -- defaults to 127.0.0.1 (local only). Use 0.0.0.0 if you "
"want to expose it to the network (E.G. in a docker image); the server has no built-in "
"authentication, so put it behind an authenticating proxy when doing so.",
)
@click.option(
"--backend",
Expand Down
92 changes: 92 additions & 0 deletions burr/tracking/common/identifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

"""Validation for the identifiers (project names, application ids) that the tracking
layer turns into filesystem paths, plus a helper that keeps the resulting paths inside
the storage directory."""

import os
import re
from typing import Optional

from burr import system

# Letters, digits, underscore, hyphen, colon and dot. This covers uuid4 (hex and
# hyphens) as well as the session/conversation style ids applications tend to pass
# through as app ids.
_ALLOWED_CHARACTERS = r"A-Za-z0-9_\-:."
IDENTIFIER_PATTERN = re.compile(f"^[{_ALLOWED_CHARACTERS}]+$")
MAX_IDENTIFIER_LENGTH = 255


def validate_identifier(value: str, what: str, *, on_windows: Optional[bool] = None) -> str:
"""Checks that ``value`` can be used as a single path component under the storage directory.

The rule: a non-empty string of at most 255 characters drawn from letters, digits, ``_``,
``-``, ``:`` and ``.``, and not the special directory names ``.`` or ``..``. On Windows
``:`` is also refused, as it is a drive/stream separator there.

:param value: the identifier to check
:param what: short label for the error message, e.g. ``"app_id"`` or ``"project"``
:param on_windows: platform override, defaults to the current platform; exposed for tests
:return: ``value`` unchanged, so the call can be used inline
:raises ValueError: if the identifier does not meet the rule
"""
if on_windows is None:
on_windows = system.IS_WINDOWS
if not isinstance(value, str):
raise ValueError(f"{what} must be a string, got {type(value).__name__}: {value!r}")
if not value:
raise ValueError(f"{what} must not be empty")
if len(value) > MAX_IDENTIFIER_LENGTH:
raise ValueError(
f"{what} must be at most {MAX_IDENTIFIER_LENGTH} characters, got {len(value)}"
)
if value in (".", ".."):
raise ValueError(f"{what} must not be '.' or '..', got {value!r}")
if not IDENTIFIER_PATTERN.match(value) or (on_windows and ":" in value):
allowed = "letters, digits, '_', '-', '.'" + ("" if on_windows else " and ':'")
raise ValueError(f"{what} may only contain {allowed}, got {value!r}")
return value


def join_within(base: str, *parts: str) -> str:
"""Joins ``parts`` onto ``base`` and checks that the result stays strictly inside ``base``.

Both sides are resolved with :func:`os.path.realpath` before comparing, so ``..`` segments
and symlinks are accounted for. The path returned is the plain join (not the resolved
form) so callers see the same spelling they would get from :func:`os.path.join`.

:param base: the directory the result must stay inside
:param parts: path components to join onto ``base``
:return: ``os.path.join(base, *parts)``
:raises ValueError: if the joined path would land outside ``base``
"""
joined = os.path.join(base, *parts)
base_resolved = os.path.realpath(base)
target_resolved = os.path.realpath(joined)
try:
inside = (
target_resolved != base_resolved
and os.path.commonpath([base_resolved, target_resolved]) == base_resolved
)
except ValueError:
# commonpath refuses to compare paths on different drives (Windows); treat as outside
inside = False
if not inside:
raise ValueError(f"Path {joined!r} is not inside the storage directory {base!r}")
return joined
47 changes: 44 additions & 3 deletions burr/tracking/server/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict

from burr.tracking.common import models
from burr.tracking.common.identifiers import join_within, validate_identifier
from burr.tracking.common.models import ChildApplicationModel
from burr.tracking.server import schema
from burr.tracking.server.schema import (
Expand Down Expand Up @@ -293,6 +294,42 @@ def get_uri(project_id: str) -> str:
DEFAULT_PATH = os.path.expanduser("~/.burr")


def _validate_identifier(value: str, name: str = "identifier") -> str:
"""Validate a project/app identifier that is used as a path component under the storage
directory. The rule itself lives in :mod:`burr.tracking.common.identifiers` and is shared
with the tracking client; this wrapper reports failures as HTTP 400.

:param value: the identifier to validate
:param name: label for the error message, e.g. ``"project_id"``
:return: the identifier if valid
:raises fastapi.HTTPException: 400 if the identifier does not meet the rule
"""
try:
return validate_identifier(value, name)
except ValueError as e:
raise fastapi.HTTPException(status_code=400, detail=str(e)) from e


def _safe_join(base: str, *parts: str) -> str:
"""Join path components and ensure the result stays inside ``base``.

Delegates to :func:`burr.tracking.common.identifiers.join_within`, which resolves symlinks
on both sides before comparing, so a link under the storage directory that points elsewhere
is rejected too. Failures are reported as HTTP 400 without echoing the storage location.

:param base: the allowed base directory
:param parts: path components to join
:return: the joined path
:raises fastapi.HTTPException: 400 if the resolved path is not inside the base directory
"""
try:
return join_within(base, *parts)
except ValueError as e:
raise fastapi.HTTPException(
status_code=400, detail="Invalid path: must be inside the storage directory."
) from e


class LocalBackend(BackendBase, AnnotationsBackendMixin):
"""Quick implementation of a local backend for testing purposes. This is not a production backend.

Expand All @@ -303,7 +340,8 @@ def __init__(self, path: str = DEFAULT_PATH):
self.path = path

def _get_annotation_path(self, project_id: str) -> str:
return os.path.join(self.path, project_id, "annotations.jsonl")
_validate_identifier(project_id, "project_id")
return _safe_join(self.path, project_id, "annotations.jsonl")

async def _load_project_annotations(self, project_id: str):
annotations_path = self._get_annotation_path(project_id)
Expand Down Expand Up @@ -464,7 +502,8 @@ async def list_apps(
limit: int = 100,
offset: int = 0,
) -> Tuple[Sequence[ApplicationSummary], int]:
project_filepath = os.path.join(self.path, project_id)
_validate_identifier(project_id, "project_id")
project_filepath = _safe_join(self.path, project_id)
if not os.path.exists(project_filepath):
return [], 0
# raise fastapi.HTTPException(status_code=404, detail=f"Project: {project_id} not found")
Expand Down Expand Up @@ -506,7 +545,9 @@ async def get_application_logs(
) -> ApplicationLogs:
# TODO -- handle partition key here
# This currently assumes uniqueness
app_filepath = os.path.join(self.path, project_id, app_id)
_validate_identifier(project_id, "project_id")
_validate_identifier(app_id, "app_id")
app_filepath = _safe_join(self.path, project_id, app_id)
if not os.path.exists(app_filepath):
raise fastapi.HTTPException(
status_code=404, detail=f"App: {app_id} from project: {project_id} not found"
Expand Down
10 changes: 9 additions & 1 deletion burr/tracking/server/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ def create_burr_ui_app(serve_static: bool = SERVE_STATIC) -> FastAPI:
This factory creates a new FastAPI instance with all Burr UI routes,
demo routers, and (optionally) static file serving configured.

The app does not authenticate requests. Serve it on localhost (the default for the
``burr`` CLI and for ``python -m burr.tracking.server.run``) or put it behind an
authenticating reverse proxy when exposing it beyond the local machine.

:param serve_static: Whether to serve the React UI static files. Defaults to
the BURR_SERVE_STATIC environment variable (true by default).
:return: A fully-configured FastAPI application.
Expand Down Expand Up @@ -450,4 +454,8 @@ def mount_burr_ui(

if __name__ == "__main__":
port = int(os.getenv("PORT", 8000)) # Default to 8000 if no PORT environment variable is set
uvicorn.run(app, host="0.0.0.0", port=port)
# Bind to localhost unless BURR_SERVER_HOST says otherwise. The server has no built-in
# authentication, so only expose it beyond the local machine (e.g. BURR_SERVER_HOST=0.0.0.0)
# behind an authenticating reverse proxy.
host = os.getenv("BURR_SERVER_HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=port)
Loading
Loading