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
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.
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.anonAccessdefaults toAnonymousAccess.NONEandWebResource.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
siteIdis omitted. Every endpoint falls back togetCurrentHostNoThrow(request)(orgetHost(request)—HostWebAPIImpl.java:49-57), which callsgetCurrentHost(request, null)and resolves the site as system user (HostWebAPIImpl.java:78-79). The caller's permissions are never consulted. Only the explicit-siteIdpath checks anything, viaHostAPI.find(siteId, user, false)inAiHostResolver.java:75-80.2. Silent cross-site config fallback.
HostAPIImpl.resolveHostNamefalls back tofindDefaultHostwhen the server name matches no site or alias (HostAPIImpl.java:140-144), andConfigService.configthen falls back to SYSTEM_HOST when the resolved site has no dotAI secrets (ConfigService.java:49-53). A client pointed atlocalhost, an unaliased hostname, or sitting behind a proxy that rewritesHostsilently spends the default (or system) site's credentials and gets a 200.3. Inconsistent model passthrough.
CompletionsResource.resolveFormforces non-admins onto the site's configured model (CompletionsResource.java:366-369).TextResource.generateRequestpassesform.modelstraight 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
@RequestCostprice, so the instance-wideRequestCostFilterbackstop (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:66andSearchResource.java:54exposeGET /testreturning a constant{"type": …}map with no auth. Harmless, but undocumented dead weight.Note that reading
providerConfigasAPILocator.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,205andCompletionsResource.resolveForm:360passrequiredBackendUser(true).requiredFrontendUser(true), andWebResource.checkRolePermissionsis 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 theAIViewTool/SearchTool/CompletionsToolviewtools.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-120runs withrespectAnonPerms=truefor exactly that reason). Site READ is still worth enforcing for a backend user passing an explicitsiteIdfor 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 wayTextResourceandCompletionsResourcealready have.Acceptance Criteria
/api/v1/ai/*resource obtains itsAppConfigthrough the shared resolution component introduced in Add OpenAI-compatible inference endpoints at /api/inference/v1 #37431 — no resource resolves a Host or callsConfigService.INSTANCE.config(...)directlyproviderConfig; theHostAPIImpl:140-144default-site andConfigService:49-53SYSTEM_HOST fallbacks no longer apply to these endpointssiteIdtargeting a site the caller cannot READ returns 403 — uniform across every endpoint, not just the paths that happen to check todaytext/generate,image/generate,searchandcompletions— no behavioral regression for the viewtool / site-visitor use caseTextResourceadopts the model pinningCompletionsResource:366-369already applies@RequestCostprice in the remote-HTTP-round-trip band (~100)GET /testprobes are removed, or documented and kept deliberately/api/v1/ai/*still green, plus new coverage for the strict-resolution and model-pinning behaviorPriority
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./api/inference/v1), from @ihoffmann-dot's feedback on Build and publish @dotcms/ai-sdk-provider (Vercel AI SDK provider for dotCMS) #37433@dotcms/ai-sdk-provider)