Lock down the local FastAPI server against unauthenticated access - #266
Lock down the local FastAPI server against unauthenticated access#266JCTec wants to merge 1 commit into
Conversation
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.
|
Thanks for this — the write-up is clear, and listing what you did not test rather than glossing over it is appreciated. Two logistics points first. I've retargeted this PR to I'd suggest holding the rebase until #254 and #272 land. #272 touches the top of On scope: would you be open to splitting this? The path jail and the CORS tightening are straightforward bug fixes with no product decision attached — Your blocking questions are noted and I'll come back on those separately. |
Lock down the local FastAPI server
Summary
The desktop backend listens on
127.0.0.1:8765with no authentication andCORS *. Any local process, and any web page that can reach loopback, can:/workspace/{path}(no resolve + jail).glb/.splatfiles 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 /healthand CORSOPTIONS:Loopback
Host— reject DNS-rebinding style hosts (evil.example).Local
Origin— reject browser requests from non-local pages. CLI/curl send noOriginand still work.Bearer token when Electron starts the API — Electron mints a 32-byte hex token, sets
MODLY_API_TOKEN, and writesuserData/api-token(0600). Requests need:or
X-Modly-Token: <token>(constant-time compare).GET /healthstays public so readiness probes still work. It now reports{ "status": "ok", "auth": "required" | "off" }.Headless
uvicornwithoutMODLY_API_TOKENremains usable on a trusted machine (token optional). Host/Origin checks stay on. Binding beyond loopback (Jetson LAN,--host 0.0.0.0) requires:CORS is no longer
*. It allowsnull,file://,app://, andhttp(s)://localhost|127.0.0.1|[::1]so the packaged app andnpm run devstill work.Content-Lengthstays exposed for drei / splat loaders.Path jail (
api/services/local_paths.py)/workspace/…,/export/…,/optimize/export,/optimize/ply-to-splatonly serve workspace-relative paths..., absolute paths, and Windows drive prefixes are rejected./optimize/mesh,/smooth,/transformaccept workspace-relative paths, or an already-imported file under a Modly temp dir./optimize/serve-fileno longer reads arbitrary disk paths. Allowed roots: the workspace, or process temp files namedmodly_splat_*/ living undermodly_import_*.POST /extensions/setup/{ext_id}rejects path-like extension ids..glb/.splatinto amodly_import_*temp dir before serving, so the original user file is not re-exposed over HTTP.Electron
apiHttpclient that attaches the token. GitHub tarball downloads are unchanged (still rawaxios).useGLTF, splat viewer,<a download>,fetchin ChatPanel) cannot set a secret header.session.webRequest.onBeforeSendHeadersinjects the token forhttp://127.0.0.1:8765/*andhttp://localhost:8765/*only.X-HuggingFace-Tokeninstead of?token=on the query string.CLI and MCP
--token,MODLY_API_TOKEN, thenuserData/api-token(macOS / Windows / Linux Electron paths).dev serve-apialways 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) setsMODLY_API_ALLOW_REMOTE=1.Docs and tests
api/README.md,tools/modly-cli/SKILL.md,docs/running-on-jetson.mddocument the token and the remote flag.api/tests/test_api_guard.py,api/tests/test_local_paths.py, CLI auth cases.scripts/run-pytests.mjsalso runstools/modly-cli/test_agent.py.What this does not change
Out of scope on purpose (happy to follow up):
setup.py/npm installfor anygithub.comURL. The “trusted” badge is still cosmetic.settings.json.fs:readFileBase64,shell:openExternal,fs:deleteDirectoryprefix check) is unchanged.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.
api/tests/test_local_paths.pyapi/tests/test_api_guard.py(FastAPI + httpx venv)X-Modly-Token200,ALLOW_REMOTEstill requires tokentest_runner/test_extension_processtools/modly-cli/test_agent.pyauth casespy_compileon changed Python filesnpm test/tscnode_modulesin that checkout)npm run dev/ packaged appcurlagainst:8765webRequestheader injecttest_api_guard.pyskips if FastAPI is not installed in the test interpreter. One pre-existing CLI failure remains on macOS:test_batch_accepts_manifest_json(/varvs/private/var). Unrelated to this change.Suggested tests before merge
npm test ./node_modules/.bin/tsc --noEmit -p tsconfig.node.jsonWith the app running (
npm run devand, if possible, a packaged Apple Silicon build):UI checks that exercise the token inject + path jail:
/workspace/…)..glband a.splatfrom outside the workspace; optimize / smooth / export still work.GET /workspace/../…andGET /optimize/serve-file?path=/etc/passwd(or a home-dir.glb) return 400, not the file.file://window: viewer, download link, and ChatPanelfetchstill authorize.Headless / Jetson:
Questions for maintainers
These are product calls this patch had to guess. Happy to change the PR to match.
Blocking for this change
Is headless
uvicornwithout a token still a supported mode?Electron always sets
MODLY_API_TOKEN. Bareuvicorn main:appstays open except for Host/Origin checks. Fail-open for dev, or fail-closed everywhere?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.
Is the token file the official CLI contract?
Electron writes
userData/api-tokenand rotates it every launch. CLI order is--token→MODLY_API_TOKEN→ that file. Fine, or Unix socket / env-only / Electron-only?Must
GET /healthstay unauthenticated?Needed for readiness. It also advertises that Modly is running (
auth: required|off).Did tightening CORS break the splat/GLB viewers?
Especially packaged Apple Silicon (
file:///Origin: null) vsnpm run dev(localhost:5173). This was not runtime-tested here.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)
Should “trusted extension” actually block install?
Any
github.com/owner/repostill runssetup.py/npm installas the user. The registry badge is cosmetic. Confirm dialog, official-only default, or signed manifests?api/resources/public_key.pemlooks like unused leftover signing.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.
Should the HF token leave plaintext
settings.json?Query-string leak is fixed; disk + process env remain.
IPC follow-up?
fs:readFileBase64(any path),shell:openExternal(no scheme allow-list),fs:deleteDirectory(startsWith). Same class of bug, different process.Pin a checksum for the python-embed tarball?
scripts/download-python-embed.jscurrently 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.