Skip to content

fix(content-drive): fold folder-scoped candidate resolution into a materialized CTE (#37229) - #37397

Open
ihoffmann-dot wants to merge 3 commits into
issue-37229-content-drive-folder-ctefrom
issue-37229-content-drive-folder-cte-impl
Open

fix(content-drive): fold folder-scoped candidate resolution into a materialized CTE (#37229)#37397
ihoffmann-dot wants to merge 3 commits into
issue-37229-content-drive-folder-ctefrom
issue-37229-content-drive-folder-cte-impl

Conversation

@ihoffmann-dot

@ihoffmann-dot ihoffmann-dot commented Sep 4, 2026

Copy link
Copy Markdown
Member

⚠️ Not validated against FR-010 — read before reviewing

This spec's own plan makes running EXPLAIN ANALYZE against 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.md
task 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

  • 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 (FR-002).
  • Scoped strictly to folder-scoped requests (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.
  • Adds a deterministic id.id ORDER BY tiebreaker (FR-001) so rows sharing the same mod_date sort reproducibly.

Known gaps

  • R3 names five site/host-scoping code paths; only two (explicit site, and ignoreSiteForFolders=true) were exercised with a test — the other three require constructing a BrowserQuery with site == null, which isn't reachable through the public withHostOrFolderId builder path as read in this pass. Flagged rather than faked.
  • FR-009 (execution-count non-regression) and the deep-pagination cursor-stability case (FR-001) have no dedicated test yet.

Test plan

  • FR-010 gate: run EXPLAIN ANALYZE per 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=BrowserAPITest
  • Confirm no System.out/System.getProperty/System.getenv introduced (checked via diff)

Branched off the approved spec branch per this repo's Spec-Kit flow (spec.md-only in PR1, not merged to main yet).

🤖 Generated with Claude Code

This PR fixes: #37229

Verification (2026-09-04, local) — correctness tests only, FR-010 still NOT run

  • 8/8 unit tests (BrowserAPIImplTest) pass.
  • 46/46 integration tests (BrowserAPITest) pass.
  • Three real bugs were found and fixed, all in this test's own permission-fixture setup, none in the CTE/tiebreaker production change: reuse of a shared/singleton test user that polluted an unrelated pre-existing test, a missing explicit permission grant (folder-level READ doesn't propagate to new content by inheritance in this flow), and an extraneous permissionIndividually() call that didn't block access as intended. Confirmed via two doesUserHavePermission sanity checks isolating the permission setup from the candidate-scan path this PR actually touches.
  • FR-010 (the mandatory EXPLAIN ANALYZE gate) 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 (commit 95b6031025) on the original #37148/#37183 reference dataset (418k contentlets), using real EXPLAIN ANALYZE + pg_stat_statements attribution — 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 via identifier_parent_path_asset_name_host_inode_key instead of idx_contentlet_mod_date. Buffer hits: ~909k baseline → 90,351. Per-request DB-time attribution: 272ms → 114ms (~2.4x).
  • Combined with a tag filter: still enters via the same good index (269,449 buffers); DB attribution 307ms → 272ms (marginal — the tag subquery itself is untouched by this fix, as expected).
  • Combined with a workflow filter: still enters via the good index (24,331 buffers, already a fast case); DB attribution 40ms → 59ms (noise-level on an already-fast case).
  • No plan instability reintroduced when the CTE is combined with workflow/tag filters — this was FR-010's actual open question, now closed.

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.

…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

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ihoffmann-dot's task in 2m 21s —— View job


Code Review — folder-scoped CTE + tiebreaker (#37229)

  • Read BrowserAPIImpl.java diff
  • Assess Semgrep SQL-injection finding
  • Trace parameter ordering (CTE prefix vs. binding order)
  • Check DB portability of materialized
  • Post review

Semgrep finding (BrowserAPIImpl.java:2172) — false positive

The CUSTOM_INJECTION-2 alert on select cvi. + workingLiveInode + candidatesCte is not exploitable, because neither concatenated value is attacker-controlled:

  • workingLiveInode is assigned from a fixed literal pair — browserQuery.showWorking || browserQuery.showArchived ? "working_inode" : "live_inode" (selectQuery, line ~2008). It can never be anything other than those two hardcoded column names. The same concatenation already existed before this PR (select cvi." + workingLiveInode), so it is not introduced here.

  • candidatesCte is assembled entirely from string constants plus parameterized predicates. Every append helper that contributes to it binds via ? placeholders into the parameters list, never inlining caller input:

    • appendFolderQueryand id.parent_path=? (line 2261)
    • appendSiteQueryand (id.host_inode = ?) (line 2242)
    • appendFileNameQueryLOWER(id.asset_name) = ? (line 2608)

    So candidatesCte contains only fixed SQL text and ? markers — no request data reaches it as syntax.

The suggested fix (allowlist-validate workingLiveInode, reject non-blank candidatesCte) is harmless as defense-in-depth, but the second half — if (UtilMethods.isSet(candidatesCte)) throw — would break the feature, since a non-blank candidatesCte is exactly the intended folder-scoped path. I'd recommend triaging this as /fp (or /ar) rather than committing the suggestion as-is. If you want the belt-and-suspenders assertion on workingLiveInode, the allowlist portion alone is fine.

New Issues

  • 🟡 Medium: BrowserAPIImpl.java:2740-2753 (appendOrderByQuery) — The id.id tiebreaker is appended for every caller that sorts (sortBy != null), not only folder-scoped requests. This contradicts the PR description's "every other caller of this shared method is byte-identical to before" framing: any non-folder caller ordering by mod_date now gets a new secondary sort key, which changes the emitted row order for mod_date-tied rows and shifts any in-flight pagination cursor derived from that order. It's a benign/beneficial determinism guarantee, but the blast radius is all callers, not just the folder case. What to verify: non-folder-scoped callers (and their existing tests/cursors) tolerate the added , id.id ordering. If the intent really was to scope it to the folder case, it needs to be gated on useFolderCte.

Notes (non-blocking)

  • Parameter binding order is correct. The CTE is prepended as the SQL prefix, and its params (folder, then site, then fileName) are added to parameters before buildSelectBaseQuery runs and before appendLanguageQuery/appendWorkflowQuery/etc. append their placeholders. Placeholder text order matches binding order in both the folder-CTE and non-CTE paths. No off-by-one in the ? sequence.
  • No double-filtering. fileName is folded into the CTE only when fileNameHandledByDb, and the later block correctly guards with fileNameHandledByDb && !useFolderCte; site filtering is applied in the CTE for useFolderCte and in the !useFolderCte branch otherwise. The two are mutually exclusive.
  • with ... as materialized is Postgres-specific, but DbConnectionFactory.getDBType() returns POSTGRESQL unconditionally (line ~490), so dotCMS is effectively Postgres-only — no portability regression.
  • FR-010 (EXPLAIN ANALYZE) is still unrun, as the PR body discloses. This review only covers correctness/shape of the diff; it says nothing about whether the CTE actually produces the intended plan/latency win. The merge gate in the PR description still stands.

Resolved

  • BrowserAPIImpl.java:2172 — Semgrep injection alert assessed as a false positive (see above); no code change required, recommend triage.

No correctness bugs found in the CTE construction or parameter binding. The one item worth acting on before merge is the ORDER BY blast-radius discrepancy (Medium).
· issue-37229-content-drive-folder-cte-impl

Comment on lines +2171 to +2172
final StringBuilder baseQuery = new StringBuilder(candidatesCte)
.append("select cvi.").append(workingLiveInode).append(" as inode ").append(baseClause);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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
  1. Validate workingLiveInode against 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 as live_inode or working_inode.

  2. Restrict candidatesCte to 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.

  3. Keep SQL structure in fixed constants and append only validated identifiers or trusted fragments. For example, build the query from a fixed SELECT template after validating workingLiveInode and identifierSource, rather than allowing untrusted text to reach new StringBuilder(...).

  4. Validate baseTypes as numeric values derived from the BaseContentType enum before appending them. Do not append arbitrary strings to the IN clause.

  5. Parameterize contentTypeIds and excludedContentTypeIds instead of concatenating them inside quoted SQL. Generate placeholders such as :contentTypeId0, bind each ID through the query’s existing params mechanism, and append only the placeholders to the SQL.

  6. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant