File size: 10,312 Bytes
971fa87 f4fe458 971fa87 f4fe458 971fa87 f4fe458 971fa87 f4fe458 971fa87 f4fe458 971fa87 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """Input validators for hosting-panel form fields.
Centralises format checks so every route that accepts a token / slug /
secret gets the same defence. Each validator returns the sanitised
value on success or raises HTTPException on failure β callers in
routes.py chain these via `Form(...) = Depends(...)` patterns or by
calling them directly.
Defence notes:
- All lengths are bounded so a multi-MB `slug` cannot DoS the DB layer
or push huge strings into HF Space names (HF rejects names > 32 chars
but we cap earlier at 64 to be safe).
- We don't allow path separators, whitespace, or `..` in slugs β
HuggingFace space names share a single DNS-like namespace and a
malicious slug could collide with another user.
- Tokens are length-checked against their format. A bot_token that's
4 chars is a junk submission; 5000 chars is a DoS attempt.
"""
from __future__ import annotations
import re
from fastapi import HTTPException, status
# ---- Format patterns -----------------------------------------------------
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,62}[a-z0-9]$")
_HF_SPACE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{1,62}[a-zA-Z0-9]$")
_HF_DATASET_RE = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
_BOT_TOKEN_LEN = (35, 60) # Telegram bot tokens: 35 chars + "bot" prefix allowed variants
_HF_TOKEN_LEN = (30, 60) # hf_xxxxx... typically 37 chars
_PROXY_TOKEN_LEN = (16, 128) # random shared secret; allow wide range
_NVIDIA_KEY_LEN = (20, 200) # nvapi-... typically ~60 chars
_MODEL_RE = re.compile(r"^[A-Za-z0-9_./-]+$")
# ---- Public validators ---------------------------------------------------
def validate_slug(raw: str) -> str:
"""Strict lowercase slug β used for `BotInstance.slug` and HF space names.
Rejects empty, leading/trailing hyphens, double hyphens, uppercase,
path separators, and anything that doesn't match `[a-z0-9][a-z0-9_-]+[a-z0-9]`.
"""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="slug is required")
slug = raw.strip().lower()
if not _SLUG_RE.match(slug):
raise HTTPException(
status_code=400,
detail=(
"slug must be 3-64 chars, lowercase, start/end with alnum, "
"and contain only a-z, 0-9, '_' or '-'"
),
)
return slug
def validate_hf_space_name(raw: str) -> str:
"""HF space name β slightly looser (allows uppercase) but still strict."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="hf_space_name is required")
name = raw.strip()
if not _HF_SPACE_RE.match(name):
raise HTTPException(
status_code=400,
detail="hf_space_name must be 3-64 alphanumeric/underscore/hyphen chars",
)
return name
def validate_hf_dataset_repo(raw: str) -> str:
"""HF dataset repo: must be `owner/name`."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="hf_dataset_repo is required")
repo = raw.strip()
if not _HF_DATASET_RE.match(repo) or repo.count("/") != 1:
raise HTTPException(
status_code=400,
detail="hf_dataset_repo must be in 'owner/name' format",
)
return repo
def validate_bot_token(raw: str) -> str:
"""Telegram bot token. Format: <bot_id>:<44-char base64-ish secret>."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="bot_token is required")
tok = raw.strip()
lo, hi = _BOT_TOKEN_LEN
if not (lo <= len(tok) <= hi):
raise HTTPException(
status_code=400,
detail=f"bot_token length must be {lo}-{hi} chars",
)
if ":" not in tok:
raise HTTPException(
status_code=400,
detail="bot_token must be in '<bot_id>:<secret>' format",
)
# Defensive: reject whitespace and control chars inside the token.
if any(c.isspace() or ord(c) < 32 for c in tok):
raise HTTPException(status_code=400, detail="bot_token contains invalid chars")
return tok
def validate_hf_token(raw: str) -> str:
"""HuggingFace user access token (`hf_...`)."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="hf_token is required")
tok = raw.strip()
lo, hi = _HF_TOKEN_LEN
if not (lo <= len(tok) <= hi):
raise HTTPException(
status_code=400,
detail=f"hf_token length must be {lo}-{hi} chars",
)
if any(c.isspace() or ord(c) < 32 for c in tok):
raise HTTPException(status_code=400, detail="hf_token contains invalid chars")
return tok
def validate_proxy_token(raw: str) -> str:
"""Shared RENDER/LLM proxy secret."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="llm_proxy_token is required")
tok = raw.strip()
lo, hi = _PROXY_TOKEN_LEN
if not (lo <= len(tok) <= hi):
raise HTTPException(
status_code=400,
detail=f"llm_proxy_token length must be {lo}-{hi} chars",
)
if any(c.isspace() or ord(c) < 32 for c in tok):
raise HTTPException(status_code=400, detail="llm_proxy_token contains invalid chars")
return tok
def validate_nvidia_api_key(raw: str) -> str:
"""NVIDIA API key (currently `nvapi-...`)."""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="nvidia_api_key is required")
key = raw.strip()
lo, hi = _NVIDIA_KEY_LEN
if not (lo <= len(key) <= hi):
raise HTTPException(
status_code=400,
detail=f"nvidia_api_key length must be {lo}-{hi} chars",
)
if any(c.isspace() or ord(c) < 32 for c in key):
raise HTTPException(status_code=400, detail="nvidia_api_key contains invalid chars")
return key
def validate_optional_nvidia_model(raw: str) -> str:
"""NVIDIA model id β e.g. `minimaxai/minimax-m3`."""
if not raw:
return "minimaxai/minimax-m3"
model = raw.strip()
if not _MODEL_RE.match(model):
raise HTTPException(
status_code=400,
detail="nvidia_model must contain only letters, digits, '_', '-', '.', '/'",
)
return model
def validate_optional_url(raw: str, field: str) -> str:
"""Optional URL field β empty allowed; otherwise basic sanity."""
if not raw:
return ""
url = raw.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=400, detail=f"{field} must start with http:// or https://")
if len(url) > 2048:
raise HTTPException(status_code=400, detail=f"{field} too long (max 2048 chars)")
return url
def validate_source_repo(raw: str, source_type: str = "hf") -> str:
"""Bot's source code location.
``hf`` β ``owner/name`` (HF Hub repo)
``github`` β ``https://github.com/owner/repo[.git]`` or any cloneable URL
``local`` β optional marker; the panel keeps the zip on disk instead.
``local`` accepts an empty string (no external repo). ``hf`` and
``github`` still require a non-empty value.
"""
if source_type == "local":
# Local bots get their source from an uploaded zip, not an
# external repo. An empty string is fine β the panel will
# store the zip path on the BotInstance row instead.
return ""
if not raw or not isinstance(raw, str):
raise HTTPException(status_code=400, detail="source_repo is required")
repo = raw.strip()
if source_type == "github":
if not (repo.startswith("http://") or repo.startswith("https://") or repo.startswith("git@")):
raise HTTPException(
status_code=400,
detail="source_repo must be a git URL (https://β¦ or git@β¦) when source_type=github",
)
if len(repo) > 256:
raise HTTPException(status_code=400, detail="source_repo too long (max 256 chars)")
return repo
# Default: HF Hub ``owner/name``.
if "/" not in repo or repo.count("/") > 1:
raise HTTPException(
status_code=400,
detail="source_repo must be in 'owner/name' format for HF Hub",
)
if not _HF_DATASET_RE.match(repo):
raise HTTPException(
status_code=400,
detail="source_repo must match 'owner/name' (alphanumeric, '-', '_', '.')",
)
if len(repo) > 256:
raise HTTPException(status_code=400, detail="source_repo too long (max 256 chars)")
return repo
def validate_source_type(raw: str) -> str:
"""``hf``, ``github`` or ``local``.
``local`` means the bot's source lives as a zip uploaded through
the create form (or replaced later via the Files tab). Deploys
from a ``local`` source aren't wired up to DeployOrchestrator yet
β the bot stays in ``pending`` until the user wires source on the
bot detail page.
"""
val = (raw or "hf").strip().lower()
if val not in ("hf", "github", "local"):
raise HTTPException(
status_code=400,
detail="source_type must be 'hf', 'github' or 'local'",
)
return val
def validate_framework(raw: str) -> str:
"""Free-form framework hint (e.g. ``docker``, ``python``, ``node``)."""
if not raw:
return "docker"
val = raw.strip().lower()
if len(val) > 64:
raise HTTPException(status_code=400, detail="framework too long (max 64 chars)")
if not re.match(r"^[a-z0-9_./-]+$", val):
raise HTTPException(
status_code=400,
detail="framework must be lowercase alphanumerics with _-./",
)
return val
def validate_branch(raw: str) -> str:
"""Git branch name. Conservative whitelist."""
if not raw:
return "main"
val = raw.strip()
if len(val) > 64:
raise HTTPException(status_code=400, detail="branch name too long (max 64 chars)")
if not re.match(r"^[A-Za-z0-9_./-]+$", val):
raise HTTPException(
status_code=400,
detail="branch may only contain alphanumerics, '.', '_', '-', '/'",
)
return val
|