Skip to content

Lock down the local FastAPI server against unauthenticated access - #266

Open
JCTec wants to merge 1 commit into
lightningpixel:mainfrom
JCTec:fix/local-api-auth
Open

Lock down the local FastAPI server against unauthenticated access#266
JCTec wants to merge 1 commit into
lightningpixel:mainfrom
JCTec:fix/local-api-auth

Conversation

@JCTec

@JCTec JCTec commented Aug 17, 2026

Copy link
Copy Markdown

Lock down the local FastAPI server

Summary

The desktop backend listens on 127.0.0.1:8765 with no authentication and CORS *. Any local process, and any web page that can reach loopback, can:

  • start GPU jobs
  • change or clear the Hugging Face token
  • read files via /workspace/{path} (no resolve + jail)
  • read arbitrary .glb / .splat files via /optimize/serve-file?path=

This PR closes that local attack surface without breaking the Electron UI, the 3D viewers, or the CLI/MCP automation contract.

What changed

Access control (api/services/api_guard.py)

Three layers, applied to every route except GET /health and CORS OPTIONS:

  1. Loopback Host — reject DNS-rebinding style hosts (evil.example).

  2. Local Origin — reject browser requests from non-local pages. CLI/curl send no Origin and still work.

  3. Bearer token when Electron starts the API — Electron mints a 32-byte hex token, sets MODLY_API_TOKEN, and writes userData/api-token (0600). Requests need:

    Authorization: Bearer <token>
    

    or X-Modly-Token: <token> (constant-time compare).

GET /health stays public so readiness probes still work. It now reports { "status": "ok", "auth": "required" | "off" }.

Headless uvicorn without MODLY_API_TOKEN remains usable on a trusted machine (token optional). Host/Origin checks stay on. Binding beyond loopback (Jetson LAN, --host 0.0.0.0) requires:

export MODLY_API_TOKEN=…
export MODLY_API_ALLOW_REMOTE=1

CORS is no longer *. It allows null, file://, app://, and http(s)://localhost|127.0.0.1|[::1] so the packaged app and npm run dev still work. Content-Length stays exposed for drei / splat loaders.

Path jail (api/services/local_paths.py)

  • /workspace/…, /export/…, /optimize/export, /optimize/ply-to-splat only serve workspace-relative paths. .., absolute paths, and Windows drive prefixes are rejected.
  • /optimize/mesh, /smooth, /transform accept workspace-relative paths, or an already-imported file under a Modly temp dir.
  • /optimize/serve-file no longer reads arbitrary disk paths. Allowed roots: the workspace, or process temp files named modly_splat_* / living under modly_import_*.
  • POST /extensions/setup/{ext_id} rejects path-like extension ids.
  • Import-by-path copies .glb / .splat into a modly_import_* temp dir before serving, so the original user file is not re-exposed over HTTP.

Electron

  • Token is created when the Python bridge starts and passed into uvicorn.
  • Main-process calls use an apiHttp client that attaches the token. GitHub tarball downloads are unchanged (still raw axios).
  • Renderer fetches (useGLTF, splat viewer, <a download>, fetch in ChatPanel) cannot set a secret header. session.webRequest.onBeforeSendHeaders injects the token for http://127.0.0.1:8765/* and http://localhost:8765/* only.
  • Hugging Face downloads send X-HuggingFace-Token instead of ?token= on the query string.

CLI and MCP

  • CLI sends the token from, in order: --token, MODLY_API_TOKEN, then userData/api-token (macOS / Windows / Linux Electron paths).
  • dev serve-api always generates a token for the child process and prints it in the JSON (it is not written over Electron’s file).
  • --allow-remote (or a non-loopback --host) sets MODLY_API_ALLOW_REMOTE=1.
  • MCP server loads the same env / token file.

Docs and tests

  • api/README.md, tools/modly-cli/SKILL.md, docs/running-on-jetson.md document the token and the remote flag.
  • New tests: api/tests/test_api_guard.py, api/tests/test_local_paths.py, CLI auth cases.
  • scripts/run-pytests.mjs also runs tools/modly-cli/test_agent.py.

What this does not change

Out of scope on purpose (happy to follow up):

  • GitHub extension install still executes setup.py / npm install for any github.com URL. The “trusted” badge is still cosmetic.
  • macOS DMG is still ad-hoc signed / not notarized.
  • Hugging Face token is still plaintext in settings.json.
  • Broad Electron IPC (fs:readFileBase64, shell:openExternal, fs:deleteDirectory prefix check) is unchanged.
  • Embedded Python tarball is still downloaded without a checksum.

How this was tested

Unit tests only on the author’s machine. The desktop app was not launched, and the live API was not hit end-to-end.

Suite Result
api/tests/test_local_paths.py pass
api/tests/test_api_guard.py (FastAPI + httpx venv) pass — health public, foreign Origin 403, bad Host 403, missing token 401, Bearer / X-Modly-Token 200, ALLOW_REMOTE still requires token
Existing test_runner / test_extension_process pass
tools/modly-cli/test_agent.py auth cases pass
py_compile on changed Python files pass
npm test / tsc not run (no node_modules in that checkout)
npm run dev / packaged app not run
Live curl against :8765 not run
Generate / import / 3D viewer / export not run
Electron webRequest header inject not run

test_api_guard.py skips if FastAPI is not installed in the test interpreter. One pre-existing CLI failure remains on macOS: test_batch_accepts_manifest_json (/var vs /private/var). Unrelated to this change.

Suggested tests before merge

npm test
./node_modules/.bin/tsc --noEmit -p tsconfig.node.json

With the app running (npm run dev and, if possible, a packaged Apple Silicon build):

curl http://127.0.0.1:8765/health
# {"status":"ok","auth":"required"}

curl http://127.0.0.1:8765/model/all
# 401

curl -H "Authorization: Bearer $(cat ~/Library/Application\ Support/Modly/api-token)" \
  http://127.0.0.1:8765/model/all
# 200

# browser-style CSRF should fail
curl -H "Origin: https://evil.example" http://127.0.0.1:8765/model/all
# 403

python tools/modly-cli/agent.py health
python tools/modly-cli/agent.py model list

UI checks that exercise the token inject + path jail:

  1. Generate a mesh and confirm the 3D / splat viewer still loads (/workspace/…).
  2. Import a .glb and a .splat from outside the workspace; optimize / smooth / export still work.
  3. Confirm a crafted GET /workspace/../… and GET /optimize/serve-file?path=/etc/passwd (or a home-dir .glb) return 400, not the file.
  4. Chat agent and model download (gated HF repo) still work; HF token must not appear in the download URL.
  5. Packaged file:// window: viewer, download link, and ChatPanel fetch still authorize.

Headless / Jetson:

export MODLY_API_TOKEN=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
# loopback: token only
# LAN / --host 0.0.0.0: also export MODLY_API_ALLOW_REMOTE=1

Questions for maintainers

These are product calls this patch had to guess. Happy to change the PR to match.

Blocking for this change

  1. Is headless uvicorn without a token still a supported mode?
    Electron always sets MODLY_API_TOKEN. Bare uvicorn main:app stays open except for Host/Origin checks. Fail-open for dev, or fail-closed everywhere?

  2. Is MODLY_API_ALLOW_REMOTE=1 + token the right LAN story?
    The Jetson guide tells people to hit the API from another machine. If LAN access is first-class, it may belong in settings rather than an env escape hatch.

  3. Is the token file the official CLI contract?
    Electron writes userData/api-token and rotates it every launch. CLI order is --tokenMODLY_API_TOKEN → that file. Fine, or Unix socket / env-only / Electron-only?

  4. Must GET /health stay unauthenticated?
    Needed for readiness. It also advertises that Modly is running (auth: required|off).

  5. Did tightening CORS break the splat/GLB viewers?
    Especially packaged Apple Silicon (file:// / Origin: null) vs npm run dev (localhost:5173). This was not runtime-tested here.

  6. Is copying imports into modly_import_* temp OK?
    Large files are duplicated; temp dirs can vanish. Prefer workspace/Imports/ instead?

Follow-ups (not in this PR)

  1. Should “trusted extension” actually block install?
    Any github.com/owner/repo still runs setup.py / npm install as the user. The registry badge is cosmetic. Confirm dialog, official-only default, or signed manifests? api/resources/public_key.pem looks like unused leftover signing.

  2. Any plan to Developer ID–sign and notarize the macOS DMG?
    Auto-update is already off on Darwin because the build is ad-hoc only.

  3. Should the HF token leave plaintext settings.json?
    Query-string leak is fixed; disk + process env remain.

  4. IPC follow-up?
    fs:readFileBase64 (any path), shell:openExternal (no scheme allow-list), fs:deleteDirectory (startsWith). Same class of bug, different process.

  5. Pin a checksum for the python-embed tarball?
    scripts/download-python-embed.js currently trusts HTTPS GitHub with no hash.

If only two of these need an answer before merge: (1) fail-open vs fail-closed and (5) did the viewer break.

The desktop backend bound 127.0.0.1:8765 with no authentication and
CORS *. Any local process or web page that can reach loopback could
start GPU jobs, change the Hugging Face token, or read files through
/workspace path traversal and /optimize/serve-file.

Require a per-session bearer token when Electron starts the API.
Reject non-loopback Host and Origin unless MODLY_API_ALLOW_REMOTE=1.
Jail workspace, export, optimize, and serve-file paths. Copy imported
meshes into a Modly temp dir so serve-file never reads arbitrary disks.

Inject the token from Electron (main-process axios and renderer
webRequest) so the 3D viewer and download links keep working. Teach
the CLI and MCP client to send --token, MODLY_API_TOKEN, or the
userData/api-token file. Stop putting the Hugging Face token in query
strings.

GET /health stays public so readiness probes still work. Headless
uvicorn without MODLY_API_TOKEN remains usable on a trusted machine.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant