Skip to content

Harden per-site authorization and site resolution on the /api/v1/ai/* endpoints #37491

Description

@fmontes

Description

Review of #37431 surfaced gaps that are shared by the already-shipped /api/v1/ai/* endpoints. None of these is remote-anonymous-exploitable — InitBuilder.anonAccess defaults to AnonymousAccess.NONE and WebResource.checkAnonymousPermissions (WebResource.java:369-385) 401s anonymous callers — but together they mean any authenticated user can spend an arbitrary site's LLM budget, on an arbitrary model, with no per-caller site check.

1. No per-caller site permission check when siteId is omitted. Every endpoint falls back to getCurrentHostNoThrow(request) (or getHost(request)HostWebAPIImpl.java:49-57), which calls getCurrentHost(request, null) and resolves the site as system user (HostWebAPIImpl.java:78-79). The caller's permissions are never consulted. Only the explicit-siteId path checks anything, via HostAPI.find(siteId, user, false) in AiHostResolver.java:75-80.

2. Silent cross-site config fallback. HostAPIImpl.resolveHostName falls back to findDefaultHost when the server name matches no site or alias (HostAPIImpl.java:140-144), and ConfigService.config then falls back to SYSTEM_HOST when the resolved site has no dotAI secrets (ConfigService.java:49-53). A client pointed at localhost, an unaliased hostname, or sitting behind a proxy that rewrites Host silently spends the default (or system) site's credentials and gets a 200.

3. Inconsistent model passthrough. CompletionsResource.resolveForm forces non-admins onto the site's configured model (CompletionsResource.java:366-369). TextResource.generateRequest passes form.model straight through with no such check (TextResource.java:105,116). Same subsystem, opposite policy — the second lets any authenticated user name an arbitrary model.

4. No cost accounting. No AI path carries a @RequestCost price, so the instance-wide RequestCostFilter backstop (web.xml:175-178, off by default) does not even register an LLM call — which parks a request thread and spends real money.

5. Unauthenticated static probes. ImageResource.java:46, EmbeddingsResource.java:66 and SearchResource.java:54 expose GET /test returning a constant {"type": …} map with no auth. Harmless, but undocumented dead weight.

Note that reading providerConfig as APILocator.systemUser() (ConfigService.java:44) is the normal dotCMS Apps design — the secret never leaves the server. The gap is authorization to use a site's credentials, not secret leakage.

Context: frontend-user access is intentional

TextResource:74, ImageResource:93, SearchResource:124,205 and CompletionsResource.resolveForm:360 pass requiredBackendUser(true).requiredFrontendUser(true), and WebResource.checkRolePermissions is any-of, not all-of (WebResource.java:428-441) — so a registered frontend user qualifies. This is intended: a site calling AI on behalf of a visitor is a supported use case, consistent with the AIViewTool / SearchTool / CompletionsTool viewtools.

That means locking these endpoints to backend users is not the fix — it would break a supported use case. It also means "READ on the resolved Host" is a near-no-op as a gate for frontend callers, since publicly delivered sites necessarily grant Host READ to CMS Anonymous / frontend roles (HostWebAPIImpl.checkHostPermission:110-120 runs with respectAnonPerms=true for exactly that reason). Site READ is still worth enforcing for a backend user passing an explicit siteId for a site they cannot see.

Goal

Retrofit /api/v1/ai/* onto the shared resolution + authorization component introduced by #37431, so both endpoint families make one authorization decision in one place instead of drifting the way TextResource and CompletionsResource already have.

Acceptance Criteria

  • Every /api/v1/ai/* resource obtains its AppConfig through the shared resolution component introduced in Add OpenAI-compatible inference endpoints at /api/inference/v1 #37431 — no resource resolves a Host or calls ConfigService.INSTANCE.config(...) directly
  • Site resolution is strict: a site with no dotAI configuration returns an error, never another site's providerConfig; the HostAPIImpl:140-144 default-site and ConfigService:49-53 SYSTEM_HOST fallbacks no longer apply to these endpoints
  • An explicit siteId targeting a site the caller cannot READ returns 403 — uniform across every endpoint, not just the paths that happen to check today
  • Frontend-user access is preserved on text/generate, image/generate, search and completions — no behavioral regression for the viewtool / site-visitor use case
  • Non-admin callers cannot select an arbitrary model on any endpoint: TextResource adopts the model pinning CompletionsResource:366-369 already applies
  • Each AI endpoint carries a @RequestCost price in the remote-HTTP-round-trip band (~100)
  • The unauthenticated GET /test probes are removed, or documented and kept deliberately
  • Existing Postman / integration coverage for /api/v1/ai/* still green, plus new coverage for the strict-resolution and model-pinning behavior
  • Release note covering the behavior change, and the PR labeled for rollback safety per ROLLBACK_UNSAFE_CATEGORIES.md (API contract change)

Priority

Medium — a real gap, but not anonymously exploitable, and it should land after #37431 so both families share one resolver rather than two.

Additional Context

Out of scope: per-site / per-token AI spend quota. The instance-wide LeakyTokenBucket (RATE_LIMIT_ENABLED, off by default) meters node resource-time, not spend, so it is not a budget control. With frontend-user access intended, a quota is the only real answer to "a site member can spend your LLM budget" — it needs its own design and issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions