unsloth/studio/backend/models/export.py
Daniel Han 19d369b08b studio: contain export and dataset paths under their configured roots
resolve_under_root and resolve_dataset_path previously returned absolute
paths unchanged, so an authenticated client could supply
save_directory="/tmp/escape" (or any other absolute path) and have the
exporter drop adapter files anywhere the server user could write. This
turned up during a recent audit pass where an authenticated POST to
/api/export/export/lora with save_directory="/tmp/lora_escape_test"
returned 200 and wrote adapter_model.safetensors, adapter_config.json,
and tokenizer files under /tmp.

The fix is two-layered:

storage_roots.py adds an _assert_contained(resolved, root) helper that
runs after path resolution and rejects any result whose realpath does
not sit under realpath(root). resolve_under_root now rejects '..'
segments and null bytes outright, and only accepts absolute inputs when
they are already inside the configured root (internal call sites that
re-resolve a stored absolute path stay idempotent;
worker.py:resolve_output_dir(output_dir) etc. continue to work).
resolve_dataset_path picks up the same containment rule, scoped to the
three dataset roots.

models/export.py adds field_validator("save_directory", mode="before")
to ExportCommonOptions and ExportGGUFRequest so bad input fails fast at
422 with a clear message rather than a 500 deep inside the resolver.
The validator rejects empty/whitespace, null bytes, control chars,
strings longer than 255 chars, absolute paths, and '..' segments.

routes/export.py:_export_details now returns os.path.relpath(output_path,
exports_root()) so the Export Complete dialog and /api/models/loras no
longer leak the absolute install prefix to the UI; the basename is
used as a last-resort fallback.

Verified end to end:
- POST /api/export/export/lora {"save_directory":"/tmp/foo"} -> 422
  "save_directory must be a name or relative path under the export
  root; absolute paths are rejected". /tmp/foo is not created.
- "../../etc/escape" -> 422 "may not contain '..' segments".
- save_directory="my_subdir" -> still accepted (400 only because the
  test had no checkpoint loaded yet, not because of validation).
- Internal idempotent re-resolve via resolve_export_dir(absolute path
  that is already under exports_root) returns the same path unchanged.
2026-05-11 12:43:49 +00:00

175 lines
5.5 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
"""
Pydantic schemas for Export API.
"""
from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Literal, Dict, Any
def _validate_save_directory(value: str) -> str:
"""Reject save_directory values that escape the configured export root.
Mirrors :func:`studio.backend.utils.paths.storage_roots.resolve_under_root`
so the rejection happens at request-parse time with a clear 422 instead of
at handler invocation time with an opaque 500.
"""
if value is None:
raise ValueError("save_directory is required")
raw = str(value).strip()
if not raw:
raise ValueError("save_directory must not be empty")
if "\x00" in raw:
raise ValueError("save_directory may not contain null bytes")
if any(ch in raw for ch in ("\r", "\n")):
raise ValueError("save_directory may not contain control characters")
if len(raw) > 255:
raise ValueError("save_directory must be <= 255 characters")
path = Path(raw).expanduser()
if path.is_absolute():
raise ValueError(
"save_directory must be a name or relative path under the "
"export root; absolute paths are rejected"
)
if ".." in path.parts:
raise ValueError("save_directory may not contain '..' segments")
return raw
class LoadCheckpointRequest(BaseModel):
"""Request for loading a checkpoint into the export backend."""
checkpoint_path: str = Field(..., description = "Path to the checkpoint directory")
max_seq_length: int = Field(
2048,
ge = 128,
le = 32768,
description = "Maximum sequence length for loading the model",
)
load_in_4bit: bool = Field(
True,
description = "Whether to load the model in 4-bit quantization",
)
trust_remote_code: bool = Field(
False,
description = "Allow loading models with custom code. Only enable for checkpoints/base models you trust.",
)
class ExportStatusResponse(BaseModel):
"""Current export backend status."""
current_checkpoint: Optional[str] = Field(
None,
description = "Path to the currently loaded checkpoint, if any",
)
is_vision: bool = Field(
False,
description = "True if the loaded checkpoint is a vision model",
)
is_peft: bool = Field(
False,
description = "True if the loaded checkpoint is a PEFT (LoRA) model",
)
class ExportOperationResponse(BaseModel):
"""Generic response for export operations."""
success: bool = Field(..., description = "True if the operation succeeded")
message: str = Field(..., description = "Human-readable status or error message")
details: Optional[Dict[str, Any]] = Field(
default = None,
description = "Optional extra details about the operation",
)
class ExportCommonOptions(BaseModel):
"""Common options for export operations that save locally and/or push to Hub."""
save_directory: str = Field(
...,
description = "Local directory where the exported artifacts will be written",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
push_to_hub: bool = Field(
False,
description = "If True, also push the exported model to the Hugging Face Hub",
)
repo_id: Optional[str] = Field(
None,
description = "Hugging Face Hub repository ID (username/model-name)",
)
hf_token: Optional[str] = Field(
None,
description = "Hugging Face access token used for Hub operations",
)
private: bool = Field(
False,
description = "If True, create a private repository on the Hub (where applicable)",
)
base_model_id: Optional[str] = Field(
None,
description = "HuggingFace model ID of the base model (for model card metadata)",
)
class ExportMergedModelRequest(ExportCommonOptions):
"""Request for exporting a merged PEFT model."""
format_type: Literal["16-bit (FP16)", "4-bit (FP4)"] = Field(
"16-bit (FP16)",
description = "Export precision / format for the merged model",
)
class ExportBaseModelRequest(ExportCommonOptions):
"""Request for exporting a non-PEFT (base) model."""
# Uses fields from ExportCommonOptions only
class ExportGGUFRequest(BaseModel):
"""Request for exporting the current model to GGUF format."""
save_directory: str = Field(
...,
description = "Directory where GGUF files will be saved",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
quantization_method: str = Field(
"Q4_K_M",
description = 'GGUF quantization method (e.g. "Q4_K_M")',
)
push_to_hub: bool = Field(
False,
description = "If True, also push GGUF artifacts to the Hugging Face Hub",
)
repo_id: Optional[str] = Field(
None,
description = "Hugging Face Hub repository ID for GGUF upload",
)
hf_token: Optional[str] = Field(
None,
description = "Hugging Face token for GGUF upload",
)
class ExportLoRAAdapterRequest(ExportCommonOptions):
"""Request for exporting only the LoRA adapter (not merged)."""
# Uses fields from ExportCommonOptions only