fix: url map 404 on different host (#35268)#35345
Conversation
|
Claude finished @gortiz-dotcms's task in 4m 6s —— View job 🔍 dotCMS Backend Review[🔴 Critical]
if (contentletSearches.isEmpty()) {
contentletSearches =
ContentUtils.pull(this.buildContentQuery(matches, contentType, context, false),
0, 2, "score", this.wuserAPI.getSystemUser(), true);
// No conhost post-filter — any tenant's content can be returned
}💡 After the fallback resolves results, post-filter by owning site: keep only contentlets whose [🟠 High]
final String cacheKey = REQUEST_CACHE_KEY
+ urlMapContext.getUri() + "|" // attacker-controlled
+ urlMapContext.getHost().getIdentifier() + "|"
+ urlMapContext.getLanguageId() + "|"
+ urlMapContext.getMode().name();💡 Use a separator that cannot appear in a URI (e.g., NUL char [🟠 High]
private static final Contentlet CONTENTLET_NOT_FOUND = new Contentlet();
// Contentlet.setInode(), setHost(), setStringProperty(), ... all available💡 Use an opaque, non-Contentlet marker ( [🟡 Medium]
return cached == CONTENTLET_NOT_FOUND ? null : (Contentlet) cached;💡 Add [🟡 Medium]
query.append(" +(conhost:")
.append(context.getHost().getIdentifier()) // not escaped
.append(" OR conhost:")💡 Wrap with [🟡 Medium]
.append(" +" + ESMappingConstants.VARIANT + ":")💡 Replace with Next steps
|
🔍 dotCMS Backend Review[🟠 High]
contentletSearches =
ContentUtils.pull(this.buildContentQuery(matches, contentType, context, false), 0, 2,
"score", this.wuserAPI.getSystemUser(), true);
// No conhost filter → all tenants searched as system user💡 After the fallback resolves a contentlet, post-filter by owning site: verify [🟠 High]
private static final Contentlet CONTENTLET_NOT_FOUND = new Contentlet();
// public setInode(), setLanguageId(), setProperty(), setBinary(), ... all available💡 Use an opaque, non-Contentlet marker: [🟠 High]
import javax.servlet.http.HttpServletRequest;
...
final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest();💡 Move request-scope caching to the caller (the filter or servlet that invokes [🟡 Medium]
final String cacheKey = REQUEST_CACHE_KEY
+ urlMapContext.getUri() + "|" // URI is attacker-controlled
+ urlMapContext.getHost().getIdentifier() + "|"
+ urlMapContext.getLanguageId() + "|"
+ urlMapContext.getMode().name();💡 Use a separator that cannot appear in a URI (e.g. NUL char is prohibited by RFC 3986), or hash the tuple: Next steps
|
…allback, log hardening - [Critical] Fix getDetailPageUri to fall back to the configured detail-page identifier when the current site has no page at the same path (e.g. detail page lives on a global host). Previously returned Optional.empty(), causing processURLMap to return empty and yield a 404 even when the detail page was accessible. - [High] Add request-scoped cache to getContentlet(UrlMapContext) so that isUrlPattern() and processURLMap(), which are both called on the same HTTP request, share a single ES lookup. Without the cache, each call issued up to 2 ES queries with the cross-site fallback; now the second call reuses the first result via a request attribute. - [Medium] Sanitize the Host header value before writing it to the debug log to prevent log-forging via newline injection (replaceAll on CR/LF/TAB). - [Medium] Change test assertion in processURLMap_contentOnDifferentSite_shouldResolveViaFallback from getName()/title comparison to getIdentifier() for an unambiguous identity check that is not vulnerable to score-ranking non-determinism. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content types scoped to a specific site may not reliably allow content creation on a different site. The cross-site fallback test now registers the URL-mapped content type on SYSTEM_HOST (idiomatic for cross-site URL mapping), while the content item and the request still come from different sites — so the host-restricted query still misses the content and the fallback is exercised as intended. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TestDataUtils.getNewsContent uses IndexPolicy.FORCE which writes immediately but does not wait for ES to make the document queryable. The fallback (site-agnostic) query may therefore see no results. Publishing the content triggers IndexPolicy.WAIT_FOR, ensuring the document is committed and searchable before the assertion runs. LIVE mode is used in the UrlMapContext to match the published version. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tCMS#35615) ## Summary - Adds **STEP 6** to the AI backend reviewer workflow (`ai_claude-backend-reviewer.yml`) - After posting the review comment, the orchestrator now submits a formal GitHub PR review: - **Critical or High findings** → `REQUEST_CHANGES` (merge blocked until resolved) - **Clean review** → `APPROVE` (automatically lifts a previous block on the next clean push) ## Motivation PR [dotCMS#35345](dotCMS#35345) had a 🔴 Critical cross-site content-bleed finding flagged by the automated reviewer — twice. It was ignored and the PR was approved and merged, leading to a production incident (Lennox ticket #36966: wrong brand content rendering on product pages after release 26.04.28-02). The previous workflow only posted a comment. Comments are easy to ignore. A formal `REQUEST_CHANGES` review cannot be bypassed with a simple Approve click — it must be explicitly dismissed or resolved. ## Setup required To fully enforce the block, enable the following in **Settings → Branches → Branch protection rules** on `main`: - ✅ Require a pull request before merging - ✅ Dismiss stale pull request approvals when new commits are pushed ## Test plan - [ ] Open a PR with a Java change that triggers a Critical/High finding — verify the bot submits `REQUEST_CHANGES` and the merge button is blocked - [ ] Push a fix — verify the bot submits `APPROVE` on the clean re-run and the block is lifted Closes dotCMS#35614 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
cross-site URL map fallback caused multi-tenant content bleed Reverts all changes introduced by PR dotCMS#35345 (fix: url map 404 on different host). The unrestricted cross-site fallback added in that PR — which removed the conhost filter from the ES query when the host-restricted query returned no results — caused a production incident (dotCMS#35616): in multi-brand/multi-tenant setups, content from one site was silently rendered on a different site's pages. This revert restores the original host-restricted single-query behavior. The getDetailPageUri() fallback (which allowed detail pages on a different host) is also reverted; it will be reintroduced correctly in the follow-up fix. This PR fixes: dotCMS#35621 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary Adds a new Claude Code slash command `.claude/commands/check-release-rollback.md` that automates rollback safety checks between any two dotCMS releases. ## What it does Given two version strings (e.g. `26.04.28-02_7149dce` → `26.04.11-02_9650131`): 1. Extracts all merged PRs between the two commits via `git log` 2. Fetches title + labels for each PR via `gh` CLI 3. Classifies each PR as: **Safe** / **Not Safe** / **Conflicting** / **Unlabeled** 4. Outputs a structured report with linked PRs and risk notes per unsafe PR 5. Returns a clear **YES / NO / CONDITIONAL** verdict 6. Optionally saves the report as a `.txt` file **Usage:** ``` /check-release-rollback 26.04.28-02_7149dce 26.04.11-02_9650131 ``` ## Motivation During a customer's production incident (ticket #36966), a manual rollback safety check was performed and revealed multiple PRs labeled `AI: Not Safe To Rollback` blocking the rollback from `26.04.28-02`. This skill automates that process so any engineer or support agent can run it in seconds. Related: - Bug: dotCMS#35616 (cross-site content bleed introduced by dotCMS#35345) - CI enforcement: dotCMS#35615 (block merges on Critical/High AI review findings) ## Test plan - [ ] Run `/check-release-rollback 26.04.28-02_7149dce 26.04.11-02_9650131` and verify output matches the manually produced report 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Fixes the runtime 404 reported in #35268, which occurs when a URL-mapped content item lives on a different site (siteB) from the one receiving the request (siteA).
What happened
The typical setup:
https://siteA.com/museum_decade/1920).The runtime 404 was caused by
URLMapAPIImpl.buildContentQuery(), which restricted the Elasticsearch query to content on the current host and SYSTEM_HOST:Content created on siteB has
conhost=siteB_id, so it was never found by the query. BecausegetContentlet()returnednull,isUrlPattern()returnedfalse, andprocessURLMap()was never even reached — making the previous fix atgetDetailPageUri()irrelevant for this scenario.What was done
buildContentQuery()now accepts arestrictToHostflag and the content lookup uses a two-step strategy:Once the siteB content is resolved,
getDetailPageUri()correctly returns siteA's configured detail page (since the content type is registered on siteA), and the page renders normally.An integration test (
processURLMap_contentOnDifferentSite_shouldResolveViaFallback) was added toURLMapAPIImplTestto cover this exact scenario.Test plan
URLMapAPIImplTest#processURLMap_contentOnDifferentSite_shouldResolveViaFallback— new test: content on siteB is resolved when requested from siteAURLMapAPIImplTestsuite — all existing tests must continue to passCloses #35268
🤖 Generated with Claude Code
This PR fixes: #35268