* Rebuild Studio branch on top of main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix security and code quality issues for Studio PR #4237 - Validate models_dir query param against allowed directory roots to prevent path traversal in /api/models/local endpoint - Replace string startswith() with Path.is_relative_to() for frontend path traversal check in serve_frontend - Sanitize SSE error messages to not leak exception details to clients (4 locations in inference.py) - Bind port-discovery socket to 127.0.0.1 instead of all interfaces in llama_cpp backend - Import datasets_root and resolve_output_dir in embedding training function to fix NameError and use managed output directory - Remove stale .gitignore entries for package-lock.json and test directories so tests can be tracked in version control - Add venv-reexecution logic to ui CLI command matching the studio command behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move models_dir path validation before try/except block The HTTPException(403) was inside the try/except Exception handler, so it would be caught and re-raised as a 500. Moving the validation before the try block ensures the 403 is returned directly and also makes the control flow clearer for static analysis (path is validated before any filesystem operations). * Use os.path.realpath + startswith for models_dir validation CodeQL py/path-injection does not recognize Path.is_relative_to() as a sanitizer. Switched to os.path.realpath + str.startswith which is a recognized sanitizer pattern in CodeQL's taint analysis. The startswith check uses root_str + os.sep to prevent prefix collisions (e.g. /app/models_evil matching /app/models). * Never pass user input to Path constructor in models_dir validation CodeQL traces taint through Path(resolved) even after a startswith barrier guard. Fix: the user-supplied models_dir is only used as a string for comparison against allowed roots. The Path object passed to _scan_models_dir comes from the trusted allowed_roots list, not from user input. This fully breaks the taint chain. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
164 lines
4 KiB
Python
164 lines
4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
|
|
def studio_root() -> Path:
|
|
return Path.home() / ".unsloth" / "studio"
|
|
|
|
|
|
def assets_root() -> Path:
|
|
return studio_root() / "assets"
|
|
|
|
|
|
def datasets_root() -> Path:
|
|
return assets_root() / "datasets"
|
|
|
|
|
|
def dataset_uploads_root() -> Path:
|
|
return datasets_root() / "uploads"
|
|
|
|
|
|
def recipe_datasets_root() -> Path:
|
|
return datasets_root() / "recipes"
|
|
|
|
|
|
def outputs_root() -> Path:
|
|
return studio_root() / "outputs"
|
|
|
|
|
|
def exports_root() -> Path:
|
|
return studio_root() / "exports"
|
|
|
|
|
|
def auth_root() -> Path:
|
|
return studio_root() / "auth"
|
|
|
|
|
|
def auth_db_path() -> Path:
|
|
return auth_root() / "auth.db"
|
|
|
|
|
|
def tmp_root() -> Path:
|
|
return Path(tempfile.gettempdir()) / "unsloth-studio"
|
|
|
|
|
|
def seed_uploads_root() -> Path:
|
|
return tmp_root() / "seed-uploads"
|
|
|
|
|
|
def unstructured_seed_cache_root() -> Path:
|
|
return tmp_root() / "unstructured-seed-cache"
|
|
|
|
|
|
def oxc_validator_tmp_root() -> Path:
|
|
return tmp_root() / "oxc-validator"
|
|
|
|
|
|
def tensorboard_root() -> Path:
|
|
return studio_root() / "runs"
|
|
|
|
|
|
def ensure_dir(path: Path) -> Path:
|
|
path.mkdir(parents = True, exist_ok = True)
|
|
return path
|
|
|
|
|
|
def ensure_studio_directories() -> None:
|
|
"""Create all standard studio directories on startup."""
|
|
for dir_fn in (
|
|
studio_root,
|
|
assets_root,
|
|
datasets_root,
|
|
dataset_uploads_root,
|
|
recipe_datasets_root,
|
|
outputs_root,
|
|
exports_root,
|
|
auth_root,
|
|
tensorboard_root,
|
|
):
|
|
ensure_dir(dir_fn())
|
|
|
|
|
|
def _clean_relative_path(
|
|
path_value: str, *, strip_prefixes: tuple[str, ...] = ()
|
|
) -> Path:
|
|
path = Path(path_value).expanduser()
|
|
parts = [part for part in path.parts if part not in ("", ".")]
|
|
while parts and parts[0] in strip_prefixes:
|
|
parts = parts[1:]
|
|
return Path(*parts) if parts else Path()
|
|
|
|
|
|
def resolve_under_root(
|
|
path_value: str | None,
|
|
*,
|
|
root: Path,
|
|
strip_prefixes: tuple[str, ...] = (),
|
|
) -> Path:
|
|
if not path_value or not str(path_value).strip():
|
|
return root
|
|
|
|
path = Path(str(path_value).strip()).expanduser()
|
|
if path.is_absolute():
|
|
return path
|
|
|
|
cleaned = _clean_relative_path(str(path), strip_prefixes = strip_prefixes)
|
|
return root / cleaned
|
|
|
|
|
|
def resolve_output_dir(path_value: str | None = None) -> Path:
|
|
return resolve_under_root(
|
|
path_value,
|
|
root = outputs_root(),
|
|
strip_prefixes = ("outputs",),
|
|
)
|
|
|
|
|
|
def resolve_export_dir(path_value: str | None = None) -> Path:
|
|
return resolve_under_root(
|
|
path_value,
|
|
root = exports_root(),
|
|
strip_prefixes = ("exports",),
|
|
)
|
|
|
|
|
|
def resolve_tensorboard_dir(path_value: str | None = None) -> Path:
|
|
return resolve_under_root(
|
|
path_value,
|
|
root = tensorboard_root(),
|
|
strip_prefixes = ("runs", "tensorboard"),
|
|
)
|
|
|
|
|
|
def resolve_dataset_path(path_value: str) -> Path:
|
|
path = Path(path_value).expanduser()
|
|
if path.is_absolute():
|
|
return path
|
|
|
|
parts = [part for part in Path(path_value).parts if part not in ("", ".")]
|
|
if parts[:2] == ["assets", "datasets"]:
|
|
parts = parts[2:]
|
|
if parts and parts[0] == "uploads":
|
|
cleaned = Path(*parts[1:]) if len(parts) > 1 else Path()
|
|
return dataset_uploads_root() / cleaned
|
|
if parts and parts[0] == "recipes":
|
|
cleaned = Path(*parts[1:]) if len(parts) > 1 else Path()
|
|
return recipe_datasets_root() / cleaned
|
|
|
|
cleaned = Path(*parts) if parts else Path()
|
|
candidates = [
|
|
dataset_uploads_root() / cleaned,
|
|
recipe_datasets_root() / cleaned,
|
|
datasets_root() / cleaned,
|
|
dataset_uploads_root() / cleaned.name,
|
|
recipe_datasets_root() / cleaned.name,
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate
|
|
return candidates[0]
|