modelbeast/server/settings.py
MODELBEAST 514ec6cdcc Security hardening from adversarial review (5-agent workflow)
Fixed confirmed findings before public exposure:
HIGH:
- upload filename path traversal → store.safe_name() strips to basename
- login rate-limit XFF bypass → key on request.client.host + per-username bucket;
  auth.check_login() burns bcrypt time on unknown users (no enumeration)
- cross-user read access → per-user isolation: guests see/use/download/delete only
  their own assets & jobs (owner sees all); WS job events scoped per-user
MEDIUM:
- unbounded upload read → bounded chunked streaming to the 1GB cap
- asset member path check → Path.is_relative_to boundary + ownership gate
- WS token-in-query-string leak → session-cookie-only WS auth
LOW:
- retry_job bypassed the per-user job cap → cap now checked on retry
- wholesale API-key injection → env_for_operator injects a paid key only to
  operators that declare it (guest local jobs never receive fal/OpenRouter keys)
- session revocation → users.session_epoch, bumped on password change
- int() 500s → 400; net-lane defense-in-depth (guests blocked by requires_env AND
  resources==net, so a mis-tagged paid op is still blocked)
+ public /api/health for serve.sh & proxy; docs/VPS.md; mb MB_TOKEN bearer auth

tests/smoke.sh: 34 checks passing incl. all new hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:25:13 +10:00

105 lines
3.6 KiB
Python

"""Settings + secrets: a key/value store surfaced to the UI and injected as env
vars into operator subprocesses. Secret values are redacted from job logs."""
# setting key -> environment variable name handed to operator subprocesses
ENV_MAP = {
"fal_key": "FAL_KEY",
"tripo_key": "TRIPO_KEY",
"meshy_key": "MESHY_KEY",
"replicate_token": "REPLICATE_API_TOKEN",
"openrouter_key": "OPENROUTER_API_KEY",
"hf_token": "HF_TOKEN",
"models_dir": "MODELBEAST_MODELS_DIR",
"archive_host": "MODELBEAST_ARCHIVE_HOST",
"archive_path": "MODELBEAST_ARCHIVE_PATH",
}
# keys whose values are secret: rendered as password fields, redacted from logs
SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "openrouter_key", "hf_token"}
# internal keys never exposed to the settings API or env injection (auth signing key)
HIDDEN_KEYS = {"auth_secret"}
DEFAULTS = {
"archive_host": "m3ultra@100.69.21.128",
"archive_path": "~/modelbeast-archive",
}
def get_all(con) -> dict:
rows = con.execute("SELECT key, value FROM settings").fetchall()
out = dict(DEFAULTS)
for r in rows:
if r["key"] in HIDDEN_KEYS:
continue # auth_secret etc. never reach the UI or env injection
out[r["key"]] = r["value"]
return out
def set_many(con, updates: dict) -> None:
for key, value in updates.items():
if key in HIDDEN_KEYS:
continue # never settable through the settings API
con.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, "" if value is None else str(value)),
)
con.commit()
def public_view(settings: dict) -> dict:
"""Mask secret values so the UI can show 'set / unset' without leaking them."""
out = {}
for key, value in settings.items():
if key in SECRET_KEYS:
out[key] = "••••••••" if value else ""
else:
out[key] = value
return out
def env_from_settings(settings: dict) -> dict:
env = {}
for key, env_name in ENV_MAP.items():
value = settings.get(key)
if value:
env[env_name] = value
# HF libraries read several token names
if settings.get("hf_token"):
env["HUGGING_FACE_HUB_TOKEN"] = settings["hf_token"]
return env
def env_for_operator(settings: dict, requires_env) -> dict:
"""Least-privilege env for an operator subprocess: non-secret infra vars + the
HF token (local ML ops need it for gated weights) ALWAYS; paid/secret API keys
ONLY if the operator declares them in requires_env. So a guest's local job never
receives the owner's fal/OpenRouter keys even in its environment."""
requires_env = set(requires_env or [])
env = {}
for key, env_name in ENV_MAP.items():
value = settings.get(key)
if not value:
continue
# paid secret keys are gated by requires_env; hf_token + non-secret infra always
if key in SECRET_KEYS and key != "hf_token" and env_name not in requires_env:
continue
env[env_name] = value
if settings.get("hf_token"):
env["HUGGING_FACE_HUB_TOKEN"] = settings["hf_token"]
return env
def secret_values(settings: dict) -> list[str]:
return [settings[k] for k in SECRET_KEYS if settings.get(k)]
def redact(text: str, secrets: list[str]) -> str:
if not text:
return text
for secret in secrets:
if secret and len(secret) >= 6:
text = text.replace(secret, "••••REDACTED••••")
return text