fix(api): require auth on model-manager and app-info endpoints (#9365)#9367
Conversation
A number of model-management and app-info routes carried no auth dependency at all. In multi-user mode this left them reachable by a fully unauthenticated network attacker, contradicting the documented model that model management is administrator-only. The highest-impact route was `GET /api/v2/models/scan_folder`, which takes an attacker-controlled filesystem path and recursively enumerates it, returning absolute paths of model-like files. Its distinct 200/400/500 responses also formed an existence/readability oracle for arbitrary paths. While auditing, `GET /api/v1/app/runtime_config` turned out to be worse: it served the raw `InvokeAIAppConfig` — including `remote_api_tokens` and the `external_*_api_key` values — in plaintext, to anyone. Changes: - `scan_folder` now requires `AdminUserOrDefault`, and every failure mode returns one generic 400 (details go to the server log) so the response cannot be used as a filesystem oracle. Arbitrary paths remain scannable by admins, consistent with `install_model`, which already accepts local paths. - Admin-only (`AdminUserOrDefault`): `missing`, `hugging_face`, `starter_models`, `stats`, `hf_login`, `external_providers/config`, `logging` (GET/POST) and the `invocation_cache` routes. All are either operator-only or rendered exclusively behind the admin-gated install panel. - Authenticated-user (`CurrentUserOrDefault`): model record reads, which ordinary users need for generation, plus `runtime_config`, `app_deps`, `patchmatch_status` and `external_providers/status`. - `runtime_config` now masks API keys and download tokens server-side rather than shipping them to the browser; the UI only ever needed the is-configured signal. Single-user mode is unaffected — `*OrDefault` resolves to a system admin when `multiuser` is off. Not changed: the binary-serving routes (image full/thumbnail, model cover image, workflow thumbnail) are consumed via `<img src>` and cannot carry a Bearer header, so closing those needs a signed-URL or cookie scheme. Closes invoke-ai#9365 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The added auth dependencies make FastAPI emit a `security: [{HTTPBearer: []}]`
requirement on each affected route, so the checked-in schema no longer matched
the generated one.
Regenerated with the openapi-checks command. The diff is exactly the 21 routes
gated in the previous commit gaining a security requirement; no route loses
one, and the rest of the document is byte-identical.
`schema.ts` is unchanged — openapi-typescript does not encode security
requirements in the generated types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Serving the runtime config to every authenticated user relied on a denylist: `_redact_config_secrets` masks the credential fields we know about, so any future secret added to `InvokeAIAppConfig` would leak by default until someone remembered to update it. Admin-only is an allowlist posture without that failure mode. Nothing outside the admin UI actually needed it. The three non-admin call sites read `config.multiuser` purely to derive "admin or single-user", which is already available from the unauthenticated `/auth/status` route as `multiuser_enabled` — the same source `useIsModelManagerEnabled` uses. The remaining fields (`max_queue_history`, `image_subfolder_strategy`) only feed admin-gated edit controls. - Extract that predicate into `useIsAdmin`, and have `useIsModelManagerEnabled` delegate to it. Semantics are preserved exactly, so this is a pure refactor. - Use it in the three settings components instead of `runtime_config`, and skip the query for non-admins so they generate no failed requests. - AboutModal's debug blob now omits the config section for non-admins. Its client-side redaction is dropped: it only ever masked `remote_api_tokens` and never the `external_*_api_key` fields, so a logged-in non-admin could read provider API keys out of it. The server now masks both. Server-side masking is kept as defense in depth — an admin's browser has no use for the raw values either. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JPPhoto
left a comment
There was a problem hiding this comment.
-
invokeai/app/api/routers/app_info.py:198:PATCH /runtime_configstill returns the unredacted runtime config. An admin changingmax_queue_historyorimage_subfolder_strategyreceives every external API key andremote_api_tokensvalue in the response, which the frontend then inserts into its RTK Query cache. This bypasses the PR's server-side redaction and its stated guarantee that even an admin browser never receives raw credentials. Return_redact_config_secrets(config)here as the GET route does. To expose this issue, add a test that configures secrets, patches a writable setting, and asserts the secrets are absent from the response. -
invokeai/app/api/routers/model_manager.py:203: Making/models/missingadmin-only breaks model selection for ordinary users.invokeai/frontend/web/src/services/api/hooks/modelsByType.ts:48and:76call this endpoint for every model hook and use its result to exclude records whose files are missing. A non-admin now receives 403,missingModelsDataremains undefined, and the hooks consequently include every missing model in generation dropdowns; selecting one fails when execution tries to load its absent files. Keep this read available throughCurrentUserOrDefault, or provide ordinary users another authenticated way to filter unavailable models. To expose this issue, add a test where a non-admin has a missing model record and verify it does not appear among selectable models. -
invokeai/app/api/routers/app_info.py:394-424: The invocation-cache routes are now admin-only, but the universally available Queue tab remains unchanged.invokeai/frontend/web/src/features/queue/components/InvocationCacheStatus.tsx:13therefore receives 403 and renders zeroed statistics, whileinvokeai/frontend/web/src/features/queue/components/ToggleInvocationCacheButton.tsx:28-35interprets the missing status as "disabled" and presents an active Enable button to connected non-admins. Clicking it always fails with the generic mutation error toast. Gate the cache panel and mutations withuseIsAdmin, or leave the status endpoint available to authenticated users while hiding admin-only controls. To expose this issue, add a frontend logic test for a connected non-admin with an authorization failure and assert that no cache mutation is offered.
… gating Three issues from JPPhoto's review of invoke-ai#9367: - `PATCH /runtime_config` echoed the updated config back unredacted, so an admin changing an unrelated setting still pulled every external API key and `remote_api_tokens` value into the browser's RTK Query cache, defeating the masking added to the GET route. It now returns `_redact_config_secrets(config)` as GET does. - `GET /models/missing` is `CurrentUserOrDefault` again, not admin-only. The frontend's model hooks (`modelsByType.ts`) subtract this set from the model list so unusable records stay out of the generation dropdowns. Under an admin-only gate a non-admin got 403, the subtraction became a silent no-op, and missing models were offered for selection, failing only at execution. - The invocation-cache panel lives on the universally available Queue tab, but its routes are admin-only. A non-admin saw zeroed statistics and an active Enable button that always 403s. The panel is now gated with `useIsAdmin`, and the enable/disable/clear hooks additionally refuse to offer the mutation to a non-admin, so the guard does not depend on the render site alone. `useIsAdmin`'s predicate is extracted as a pure `getIsAdmin` so it can be unit-tested alongside the new cache-control predicates. Tests: - `test_runtime_config_patch_response_has_no_secrets` patches a writable setting with secrets configured and asserts they are absent from the response. - `test_non_admin_can_filter_out_missing_models` gives a non-admin one present and one missing model record and checks the missing one is not selectable. - `invocationCacheControls.test.ts` asserts a connected non-admin is offered no cache mutation, and that admin semantics are unchanged. openapi.json is unchanged: `/models/missing` keeps the same HTTPBearer security requirement either way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — all three confirmed and fixed in f8dad23. 1. 2. 3. Invocation cache panel on the Queue tab. Confirmed, and the
Verified: |
… gating Backend: - scan_for_models: guard scan_path before pathlib.Path() — the query param defaults to None and Path(None) raised a TypeError (500) when omitted. - scan_for_models: mid-scan failures return a detail-free 500 again instead of the generic 400. By that point is_dir() has confirmed existence, so a distinct status leaks nothing, and it restores the caller-error vs server-fault distinction for admins and retrying clients. Details still go to the log only. - _redact_config_secrets docstring no longer overclaims: coverage is by naming convention, and a differently-named future credential must extend the function. - openapi.json/schema.ts regenerated — the list_missing_models docstring was edited after the last regeneration, which would have failed typegen CI. Tests: - New default-deny meta-test walks app.routes and asserts every route carries an auth dependency or sits on an explicit 12-entry public allowlist, with a staleness check in the other direction. A hand-maintained route list cannot catch the next unauthenticated route — the exact failure mode behind invoke-ai#9365. - The oracle test no longer scans '/' (a real, unbounded ModelSearch walk of the host filesystem); it uses tmp_path fixtures, and the mid-scan-failure path is exercised deterministically by stubbing ModelSearch. - New test for the missing-scan_path 400. Frontend: - SettingsModal hides the Max Queue History field for non-admins instead of rendering it permanently blank-and-disabled (the backing runtime_config query is skipped for them), matching SettingsImageSubfolderStrategySelect. - onModelInstallError only fetches HF token status for admins: the event is broadcast to every client, but /hf_login is admin-only, so every non-admin session fired a doomed request and logged a spurious 403. - Consolidated five inline copies of the admin predicate (UseCacheCheckbox, useStarterModelsToast, NoContentForViewer, ModelPicker, UpscaleWarning) onto useIsAdmin, and getIsCustomNodesEnabled now delegates to getIsAdmin. The toast/viewer/picker copies treated a still-loading setup status as admin=true, disagreeing with the canonical predicate. - getIsAdmin unit tests moved to a colocated useIsAdmin.test.ts per the frontend CLAUDE.md colocation rule; the cache-control tests no longer import it. Test results: tests/app/routers 500 passed; frontend vitest 1354 passed; eslint/prettier/tsc clean; production bundle rebuilt via vite build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed b39f282 — fixes from a self-review pass over the branch. Correctness
Hardening the fix itself
Frontend
Verified: |
…y its formatting Local ruff passed but CI pins ruff@0.11.2, which flags zip() without strict= (B905) and formats two signatures differently. Replaced the zip with a plain loop over the paths and ran the pinned version's formatter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JPPhoto
left a comment
There was a problem hiding this comment.
-
tests/app/routers/test_model_manager_authorization.py:154: The new default-deny check exemptsPOST /api/v1/images/solely becausecreate_image_upload_entrycurrently returns501. Unlike the browser image routes, this state-mutating endpoint has no reason to be public; ifinvokeai/app/api/routers/images.py:183is later implemented without adding authentication, the allowlist will silently approve the new unauthenticated upload behavior and defeat the test's purpose. AddCurrentUserOrDefaultnow and remove the exemption. Test: In multiuser mode, assert that an unauthenticatedPOST /api/v1/images/returns401while an authenticated request reaches the stub and returns501. -
tests/app/routers/test_model_manager_authorization.py:43: The authorization matrix omits two routes made admin-only by this PR:POST /api/v1/app/loggingandGET /api/v1/app/invocation_cache/status. The default-deny test only detects the complete absence of authentication, so changing either dependency fromAdminUserOrDefaulttoCurrentUserOrDefaultwould still pass every test and expose a server-wide control or operator statistic to non-admins. Test: Add both endpoints toADMIN_ONLY_ROUTES, addPOST /api/v1/app/loggingtoPROTECTED_ROUTES, and assert unauthenticated requests receive401and authenticated non-admin requests receive403.
…e admin matrix - create_image_upload_entry (POST /api/v1/images/) now requires CurrentUserOrDefault. It was allowlisted as public only because it is a 501 stub, but a later implementation would have shipped unauthenticated without the route-audit test noticing. The allowlist entry is removed; unauthenticated requests get 401 and an authenticated request still reaches the stub (501, covered by a new test). - POST /api/v1/app/logging and GET /api/v1/app/invocation_cache/status added to the authorization matrices (both PROTECTED_ROUTES and ADMIN_ONLY_ROUTES for the former, ADMIN_ONLY_ROUTES for the latter), so demoting either dependency from AdminUserOrDefault would now fail a test. - openapi.json regenerated (HTTPBearer marker on the upload-entry op); schema.ts unchanged by typegen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @JPPhoto — both points confirmed and fixed in 6e01b4b.
Authorization matrix gaps: All 46 authorization tests and the full |
|
I found and repaired this:
|
Summary
Fixes #9365. A number of model-management and app-info routes carried no auth dependency at all, so in multi-user mode they were reachable by a fully unauthenticated network attacker — contradicting the documented model that model management is administrator-only.
The reported route,
GET /api/v2/models/scan_folder, takes an attacker-controlled filesystem path and recursively enumerates it, returning absolute paths of model-like files. Its distinct 200/400/500 responses also formed an existence/readability oracle for arbitrary paths. Thanks to @geo-chen for the report.An AST sweep of all 19 routers found 11 unauthenticated model-manager routes and 12 in
app_info.py.While auditing I found something with higher impact than the reported issue.
GET /api/v1/app/runtime_configserved the rawInvokeAIAppConfigto anyone — includingremote_api_tokens(bearer tokens used for authenticated model downloads) and all fourexternal_*_api_keyvalues, in plaintext. The frontend only redacts them client-side in the About modal, which confirms they were being shipped to the browser.Deployments running multi-user mode on an untrusted network should rotate any configured provider API keys and remote API tokens. Note this was not only reachable unauthenticated: because the About modal's client-side redaction never covered the
external_*_api_keyfields, any logged-in non-admin could also read them.Changes
scan_folderrequiresAdminUserOrDefault, and every failure mode now returns one generic 400 with details going to the server log, removing the filesystem oracle.AdminUserOrDefault):missing,hugging_face,starter_models,stats,hf_login,external_providers/config,logging(GET/POST), and theinvocation_cacheroutes. All are either operator-only with no frontend caller, or rendered exclusively behind the admin-gated install panel.CurrentUserOrDefault): model record reads, plusapp_deps,patchmatch_status,external_providers/status.runtime_configis administrator-only, and additionally masks API keys and download tokens server-side. Nothing outside the admin UI needed it: the three non-admin call sites readconfig.multiuseronly to derive "admin or single-user", which already comes from/auth/status'smultiuser_enabled. That predicate is now a shareduseIsAdminhook, whichuseIsModelManagerEnableddelegates to (semantics preserved exactly).remote_api_tokensbut never theexternal_*_api_keyfields, so any logged-in non-admin could copy provider API keys out of the debug blob. The config section is now admin-only and the server masks both.Single-user mode is unaffected —
*OrDefaultresolves to a system admin whenmultiuseris off.Two deviations from the issue's suggested remediation
scan_pathallowlist. The issue recommends confiningscan_pathtomodels_path. I skipped it:install_modelalready accepts arbitrary local paths from admins, so an allowlist would be inconsistent and would break the legitimate "scan my external model drive" workflow. Auth is the real boundary; the oracle is closed separately via uniform errors.list_model_recordsisCurrentUserOrDefault, not admin. The issue suggestsAdminUserOrDefault, but that would break generation for every non-admin, since the model dropdowns depend on this route.Not fixed here
get_image_full,get_image_thumbnail,get_model_imageandget_workflow_thumbnailremain unauthenticated. They are consumed via<img src>and so cannot carry a Bearer header — closing them requires a signed-URL or cookie scheme, which is a separate design change.GET /api/v1/app/versionis intentionally left public.Testing
tests/app/routers/test_model_manager_authorization.py: 20 routes parametrized for 401-when-unauthenticated, 11 for 403-when-non-admin, plus oracle-uniformity and secret-redaction tests.ApiDependenciesand notauth_dependencies, so they broke once the routes gained an auth dep. Fixed, and both routers are now registered inconftest.py's_PATCHED_API_DEPENDENCIES_MODULES.openapi.jsonregenerated: the diff is exactly the 21 gated routes gaining asecurity: [{HTTPBearer: []}]requirement. No route loses one and the rest of the document is byte-identical — which also serves as an independent record of which routes previously had no security requirement at all.schema.tsis unchanged, since openapi-typescript does not encode security requirements in the generated types, so there is no frontend type churn.InstallModels, whichModelPane.tsxshows only whenuseIsModelManagerEnabled()is true.🤖 Generated with Claude Code