Harden per-site authorization and site resolution on the /api/v1/ai/* endpoints
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
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 itsAppConfigthrough the shared resolution component introduced in #37431 — no resource resolves a Host or callsConfigService.INSTANCE.config(...)directly - Site resolution is strict: a site with no dotAI configuration returns an error, never another site's
providerConfig; theHostAPIImpl:140-144default-site andConfigService:49-53SYSTEM_HOST fallbacks no longer apply to these endpoints - An explicit
siteIdtargeting 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,searchandcompletions— no behavioral regression for the viewtool / site-visitor use case - Non-admin callers cannot select an arbitrary model on any endpoint:
TextResourceadopts the model pinningCompletionsResource:366-369already applies - Each AI endpoint carries a
@RequestCostprice in the remote-HTTP-round-trip band (~100) - The unauthenticated
GET /testprobes 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.
- Surfaced during review of #37431 (
/api/inference/v1), from @ihoffmann-dot's feedback on #37433 - #37431 introduces the shared resolver this issue adopts, so it should land first
- Sibling client-side work: #37433 (
@dotcms/ai-sdk-provider)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the shared resolution component from #37431, then trace AiHostResolver.java, ConfigService.java, HostAPIImpl.java, and each /api/v1/ai resource. Compare model handling in CompletionsResource.java and TextResource.java, and inspect the existing Postman/integration coverage. Done means strict site authorization and resolution, consistent model and request-cost policies, deliberate /test probe handling, passing coverage, and the required release note.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- api, backend, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100