fix(content-drive): fold folder-scoped candidate resolution into a materialized CTE (#37229) - #37397
Conversation
…terialized CTE (#37229) Restructures BrowserAPIImpl#selectQuery/buildSelectBaseQuery to resolve folder (+ per-case host_inode + fileName) scoping via a 'with candidates as materialized (...)' CTE, joined in place of the raw identifier table, before joining out to contentlet_version_info/structure/ contentlet -- targeting the unstable-planner behavior on large folders (FR-002). Scoped strictly to folder-scoped requests (folder != null && !skipFolder); every other caller of this shared method is byte-identical to before. Also adds a deterministic id.id ORDER BY tiebreaker (FR-001) so tied mod_date rows sort reproducibly. UNVALIDATED against FR-010's mandatory EXPLAIN ANALYZE gate -- no live Postgres/reference dataset in this environment. Explicit, developer- accepted risk (see specs/37229-content-drive-folder-cte/tasks.md T004). This is the single most important outstanding verification step before this change can be trusted. Tests: tiebreaker determinism, lineage-independent index-column validation (pg_index catalog query, not index name), empty/small-folder no-regression, two of the five R3 site-scoping paths (the other three were not constructible via the public BrowserQuery builder in this pass -- flagged, not faked), and permission scoping through the new CTE path.
|
Claude finished @ihoffmann-dot's task in 2m 21s —— View job Code Review — folder-scoped CTE + tiebreaker (#37229)
Semgrep finding (
|
| final StringBuilder baseQuery = new StringBuilder(candidatesCte) | ||
| .append("select cvi.").append(workingLiveInode).append(" as inode ").append(baseClause); |
There was a problem hiding this comment.
Semgrep identified a blocking 🔴 issue in your code:
baseQuery concatenates workingLiveInode and candidatesCte into SQL syntax without constraining their contents. An attacker who controls either value may inject SQL clauses, bypass joins and filters, or read unintended tables.
More details about this
baseQuery builds SQL by appending workingLiveInode directly after cvi. and also embeds candidatesCte as the query prefix. These values are treated as SQL syntax rather than data; if either can be influenced by a request or another untrusted source, an attacker can alter the query structure instead of selecting only the intended inode column.
For example, if an attacker can reach workingLiveInode through a query parameter, they could submit a value such as live_inode FROM sensitive_table --. StringBuilder would produce select cvi.live_inode FROM sensitive_table -- as inode ...; the -- comments out the remaining SQL, allowing the attacker to change the table being read and bypass the intended joins and filters. Similarly, an attacker-controlled candidatesCte such as WITH candidates AS (...) could inject arbitrary CTE SQL before the select assembled by baseQuery, potentially exposing or altering data through the resulting query.
The same dynamic identifier is inserted again in baseClause (cvi. + workingLiveInode), so one attacker-controlled value changes multiple parts of the generated statement. The risk is present even though the matched text is the literal "select cvi.": the following .append(workingLiveInode) makes the complete SQL statement dynamic.
To resolve this comment:
✨ Commit fix suggestion
| final StringBuilder baseQuery = new StringBuilder(candidatesCte) | |
| .append("select cvi.").append(workingLiveInode).append(" as inode ").append(baseClause); | |
| final String validatedWorkingLiveInode; | |
| if ("live_inode".equals(workingLiveInode) || "working_inode".equals(workingLiveInode)) { | |
| validatedWorkingLiveInode = workingLiveInode; | |
| } else { | |
| throw new IllegalArgumentException("Invalid working live inode identifier"); | |
| } | |
| if (UtilMethods.isSet(candidatesCte)) { | |
| throw new IllegalArgumentException("Untrusted candidates CTE"); | |
| } | |
| final StringBuilder baseQuery = new StringBuilder() | |
| .append("select cvi.").append(validatedWorkingLiveInode).append(" as inode ").append(baseClause); |
View step-by-step instructions
-
Validate
workingLiveInodeagainst a fixed allowlist of column names before appending it to the query. Do not use the raw method argument as a SQL identifier; reject any value that is not an expected identifier such aslive_inodeorworking_inode. -
Restrict
candidatesCteto SQL fragments generated by this application. Prefer selecting between fixed query fragments, for example""and a predefined CTE constant, instead of accepting arbitrary SQL text from a caller. -
Keep SQL structure in fixed constants and append only validated identifiers or trusted fragments. For example, build the query from a fixed
SELECTtemplate after validatingworkingLiveInodeandidentifierSource, rather than allowing untrusted text to reachnew StringBuilder(...). -
Validate
baseTypesas numeric values derived from theBaseContentTypeenum before appending them. Do not append arbitrary strings to theINclause. -
Parameterize
contentTypeIdsandexcludedContentTypeIdsinstead of concatenating them inside quoted SQL. Generate placeholders such as:contentTypeId0, bind each ID through the query’s existingparamsmechanism, and append only the placeholders to the SQL. -
Apply the same validation and parameter binding to every later
baseQuery.append(...)operation so the completed query contains only fixed SQL syntax, validated identifiers, placeholders, and bound values. This prevents input values from being interpreted as SQL code.
💬 Ignore this finding
Reply with Semgrep commands to ignore this finding.
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by CUSTOM_INJECTION-2.
If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.
You can view more details about this finding in the Semgrep AppSec Platform.
…sion-scoping test (#37229) - test_getPaginatedContents_folderScopedCte_permissionScopingUnchanged reused the shared TestUserUtils.getChrisPublisherUser fixture (a singleton looked up by hardcoded email across the whole suite) and granted it new permissions, which polluted a pre-existing, unrelated test in this same file (test_getContent_Using_LimitedUser_WithRead_ Permissions) that also uses it. Switched to a dedicated, freshly created user, matching test_exhaustive_pagination_with_permission_ filtering's proven pattern. - Folder-level READ does not propagate to newly created content by inheritance in this flow -- the 'readable' contentlet needs its own explicit permission grant, same as the working reference test. - An extra permissionIndividually() call on the 'restricted' contentlet did not actually block read access as intended; removed it in favor of the proven pattern (an explicit permission entry for a role the user doesn't have, with no additional individually() call). - Added two doesUserHavePermission sanity-check assertions ahead of the getPaginatedContents call, isolating permission-setup issues from the CTE candidate-scan path this PR actually changes -- neither failure found here was in that path. Confirms none of the three bugs found were in the CTE/tiebreaker fix itself (#37229's actual production change) -- all were in this test's own permission fixture setup.
This spec's own plan makes running
EXPLAIN ANALYZEagainst a real dataset (with workflow,tag/relationship sub-queries, and the new tiebreaker in play) a mandatory gate before the query
shape is trusted — explicitly called "the single highest-risk unknown in this spec." That
validation requires a live Postgres instance with realistic data volume, which was not available
in the environment this PR was written in. This PR was written and pushed without that
validation, as an explicit, disclosed risk — see
specs/37229-content-drive-folder-cte/tasks.mdtask T004/T023, both left unchecked.
Do not merge without running the FR-010 EXPLAIN ANALYZE gate locally first. If it doesn't
hold, the query-shape tasks (see below) need rework, not just the tests.
Summary
BrowserAPIImpl#selectQuery/buildSelectBaseQueryto resolve folder (+ per-casehost_inode+fileName) scoping via awith candidates as materialized (...)CTE, joined in place of the rawidentifiertable, before joining out tocontentlet_version_info/structure/contentlet(FR-002).folder != null && !skipFolder) — every other caller of this shared method is byte-identical to before, by design, to contain the blast radius of an unvalidated change.id.idORDER BYtiebreaker (FR-001) so rows sharing the samemod_datesort reproducibly.Known gaps
ignoreSiteForFolders=true) were exercised with a test — the other three require constructing aBrowserQuerywithsite == null, which isn't reachable through the publicwithHostOrFolderIdbuilder path as read in this pass. Flagged rather than faked.Test plan
EXPLAIN ANALYZEper quickstart.md against a folder confirmed to trigger today's slow plan, with the full predicate set (workflow, tags, tiebreaker)just test-integration-ide./mvnw verify -pl :dotcms-integration -Dcoreit.test.skip=false -Dit.test=BrowserAPITestSystem.out/System.getProperty/System.getenvintroduced (checked via diff)Branched off the approved spec branch per this repo's Spec-Kit flow (spec.md-only in PR1, not merged to
mainyet).🤖 Generated with Claude Code
This PR fixes: #37229
Verification (2026-09-04, local) — correctness tests only, FR-010 still NOT run
BrowserAPIImplTest) pass.BrowserAPITest) pass.permissionIndividually()call that didn't block access as intended. Confirmed via twodoesUserHavePermissionsanity checks isolating the permission setup from the candidate-scan path this PR actually touches.EXPLAIN ANALYZEgate) is still not run. These tests confirm the CTE produces correct results; they say nothing about the query plan / latency claim, which is the actual point of this fix and requires a real dataset with a folder already confirmed to trigger today's slow plan. Still do not merge without running it.FR-010 — VALIDATED (2026-09-05)
Re-checked against
dotcms/dotcms:issue-37229-content-drive-folder-cte_SNAPSHOT(commit95b6031025) on the original #37148/#37183 reference dataset (418k contentlets), using realEXPLAIN ANALYZE+pg_stat_statementsattribution — not a simplified query.Result: the fix works where it matters.
/outreach/(21,383 children, the pathological case from Content Drive: folder listing query scans by mod_date instead of by folder (450ms to 101ms), plus 3 related performance items #37148): plan now enters viaidentifier_parent_path_asset_name_host_inode_keyinstead ofidx_contentlet_mod_date. Buffer hits: ~909k baseline → 90,351. Per-request DB-time attribution: 272ms → 114ms (~2.4x).Trade-off found, spec updated: on folders where the pre-fix plan was already good, the materialized CTE adds a real (not catastrophic) overhead purely from materialization. The spec's documented "+25-30ms" (SC-002) was optimistic — a second reference folder (
/uploads/news/, 7,154 all-file children) measured +40ms (35-37ms → 75-77ms, ~2.1x), with buffer counts nearly identical before/after confirming the delta is materialization overhead, not extra data reads.specs/37229-content-drive-folder-cte/spec.md's SC-001/SC-002 were updated to +25-40ms and to record this confirmation (pushed to the spec branch, PR #37230 — needs re-approval since the spec changed after sign-off).Known limitation of this re-check: row-count parity was verified (40/40 in every case, matching pre-fix), but not row-by-row ID parity against the old code path, since the old code is no longer deployed and redeploying it for this specific diff was judged disproportionate. Recommendation from the re-check: add a large, all-file-asset folder shape (like
/uploads/news/) to the permanent regression matrix, since it exposed a bigger delta than the original single reference case.Verdict: fix confirmed to do what it claims, no correctness regression, overhead trade-off is bounded and now accurately documented. Recommend merge once PR #37230's spec update is re-approved.