Skip to content

sec: harden UtilMethods.getURL — restrict to http(s) and block non-routable hosts - #36969

Queued
mbiuki wants to merge 6 commits into
mainfrom
sec/harden-utilmethods-geturl-668
Queued

sec: harden UtilMethods.getURL — restrict to http(s) and block non-routable hosts#36969
mbiuki wants to merge 6 commits into
mainfrom
sec/harden-utilmethods-geturl-668

Conversation

@mbiuki

@mbiuki mbiuki commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes dotCMS/private-issues#668

Proposed Changes

UtilMethods.getURL(String) opened a connection to any URI passed to it and returned the body, with no scheme allowlist and no host restriction. It is exposed to the Velocity template context as $UtilMethods.getURL (VelocityUtil), which makes it reachable by any user with design-layer (template/container) write access — a lower bar than administrator. That allowed local file read (file://) and full-read SSRF to loopback / link-local (cloud-metadata) / private hosts. The Velocity introspector denylist blocks reflection/Runtime/etc. but is class-based and does not cover this method.

This change makes getURL self-defend:

  • Allow only http / https schemes (blocks file:, jar:, ftp:, gopher:, …).
  • Reject loopback / any-local / link-local / site-local / multicast target hosts.
  • Disable HTTP redirect following (autofix commit) so a redirect can't bounce to an internal target.
  • On a blocked request, return an empty result and log via SecurityLogger.

Scope / compatibility: getURL has no callers in the Java source or in any shipped .vtl, so there is no functional impact. $UtilMethods stays in the template context and its other helpers (isSet, date/HTML utilities used by ~24 bundled templates) are unchanged.

Regression test

SstiGetUrlReproTest guards the fix — asserts file:// read returns empty and a loopback request is never made. Verified locally:

./mvnw test -pl :dotcms-core -Dtest=SstiGetUrlReproTest  ->  Tests run: 1, Failures: 0, Errors: 0  (BUILD SUCCESS)

Notes

Sensitive detail, full reproduction, and follow-up hardening (audit other context tools $import/$filetool/$velutil/$dotcontent; move the introspector to an allowlist; split content-edit from template-edit permissions) are tracked privately in dotCMS/private-issues#668.

🤖 Generated with Claude Code

…ble hosts

UtilMethods.getURL(String) fetched any URI a caller passed and returned the body, with
no scheme allowlist or host restriction. It is exposed to the Velocity template context
as $UtilMethods.getURL (VelocityUtil), so any design-layer (template/container) user
could use it for local file read (file://) and full-read SSRF to loopback / link-local
(cloud metadata) / private hosts. The introspector denylist blocks reflection/Runtime/etc
but not this method.

Restrict getURL to http/https and reject loopback/any-local/link-local/site-local/
multicast targets, returning empty + a SecurityLogger entry otherwise. getURL has no
callers in Java or shipped .vtl, so no functional impact; $UtilMethods and its other
helpers (isSet, date/HTML utils used by ~24 templates) are untouched.

Adds SstiGetUrlReproTest as a regression guard (file:// read blocked, loopback SSRF
blocked). Verified: ./mvnw test -pl :dotcms-core -Dtest=SstiGetUrlReproTest -> BUILD SUCCESS.

Details: dotCMS/private-issues#668

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mbiuki mbiuki added the Team : Security Issues related to security and privacy label Aug 7, 2026
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Aug 7, 2026
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @wezell's task in 2m 25s —— View job


PR Review — harden UtilMethods.getURL

  • Read diff, final method, and CircuitBreakerUrl shared path
  • Verify prior review findings addressed
  • Check for new issues

The change is solid and correctly addresses all the earlier review feedback. getURL now routes through CircuitBreakerUrl (the same hardened path $import uses) with redirects disabled, backed by a strong pre-connect host guard. No blocking issues.

New Issues

None.

Existing

  • 🟡 Medium: UtilMethods.java:1264-1284TOCTOU / DNS rebinding. The strong isNonRoutable() check resolves getHost() via getAllByName, but CircuitBreakerUrl.doString() re-resolves DNS independently at connect time (UtilMethods.java:279). A rebinding host that returns a public A record on the first lookup and a private one on the second is only gated by the weaker IPUtils.isIpPrivateSubnet inside CircuitBreakerUrl (CircuitBreakerUrl.java:274) — which, as rsh1k noted, misses 127.0.0.2+, 0.0.0.0, IPv6, and link-local beyond 169.254.169.254. So the strong guard is bypassable via rebinding. Non-blocking — explicitly acknowledged and deferred to dotCMS/private-issues#668 (closing it means pinning the connection to the validated InetAddress, a client-level change for all consumers). Noted here only so it stays tracked.

Resolved

  • UtilMethods.java — now fetches via CircuitBreakerUrl instead of the hand-rolled URLConnection (wezell). file:///jar: read path is gone (http(s)-only), and System.setProperty(...) calls removed (aligns with the no-System.* rule).
  • UtilMethods.java:1265 — multi-address bypass closed: iterates all A/AAAA records from getAllByName and rejects if any is non-routable.
  • UtilMethods.java:1296-1313isNonRoutable() covers the IPUtils gaps rsh1k listed: full 127/8, 0.0.0.0, all of 169.254/16 (incl. ECS creds 169.254.170.2), IPv6 (::1, fe80::/10, fd00::/7 ULA), and IPv4 CGNAT 100.64/10. Bit masks verified correct (fc00::/7b[0] & 0xFE == 0xFC; 100.64/10b[0]==100 && b[1] & 0xC0 == 0x40).
  • ✅ Redirects disabled (setAllowRedirects(false)) so a 3xx can't bounce to an internal target after the check.

Notes (non-blocking)

  • getURL now returns an empty StringBuffer on a blocked/failed request rather than the previous null (old non-200 path returned null). Since the PR confirms getURL has no Java or .vtl callers, this is a benign, arguably safer, contract change — worth a one-line mention in the PR body.
  • IPv4-mapped IPv6 literals (::ffff:127.0.0.1) are safe here: the JDK normalizes them to Inet4Address, so the loopback/link-local predicates still fire.

Good, well-scoped security fix — approve once the TOCTOU follow-up stays tracked in #668.

· sec/harden-utilmethods-geturl-668

@mbiuki
mbiuki requested a review from rsh1k August 7, 2026 22:44
@mbiuki mbiuki moved this to In Review in dotCMS - Product Planning Aug 7, 2026
@mbiuki mbiuki self-assigned this Aug 7, 2026
Set setInstanceFollowRedirects(false) on the HttpURLConnection so a 3xx
response cannot redirect the request to an internal host after the
loopback/link-local host check has passed. Closes the redirect-to-internal
residual noted in the PR description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMzJB7ujVZN6N2nV1xgJng
@mbiuki

mbiuki commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

@mbiuki

mbiuki commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

CI results (head 4217d59c8f):

Check Result
PR Build / Initial Artifact Build ✅ pass
PR Test / JVM Unit Tests (runs SstiGetUrlReproTest) ✅ pass
Add Issue to PR / link-issue ✅ pass
PR Test / Integration Tests – MainSuite 2a ❌ fail — unrelated

The MainSuite 2a failure is com.dotmarketing.portlets.personas.business.PersonaAPITest (AssertionError: expected:<200> but was:<500> in the auth/ResponseUtil path). This PR's diff is two files — the UtilMethods.getURL guard and its regression test — with no persona/auth code touched, and JVM Unit Tests (which exercises the change) is green. It's a flaky/environmental failure, not caused by this change; re-ran the failed job.

The security change itself is verified: SstiGetUrlReproTest passes in CI (JVM Unit Tests) and locally (file:// read blocked, loopback SSRF blocked). Details in dotCMS/private-issues#668.

🤖 Generated with Claude Code

Comment thread dotCMS/src/main/java/com/dotmarketing/util/UtilMethods.java Outdated
…host guard

Addresses review on #36969 (wezell, rsh1k, automated review):
- Fetch now uses the shared CircuitBreakerUrl client (same path as $import): circuit
  breaker, timeout, IPUtils private-subnet gate, redirects disabled. Being HTTP-only it
  also removes the file:// read path. Replaces the hand-rolled URLConnection.
- Kept a strong pre-connect host guard because IPUtils' default blacklist is weaker
  (misses 127/8, 0.0.0.0, full 169.254/16 incl. 169.254.170.2, IPv6). Now resolves via
  getAllByName and rejects if ANY address is non-routable (multi-record bypass), via a new
  isNonRoutable() helper that also covers IPv6 ULA (fd00::/7) and IPv4 CGNAT (100.64/10).
- Fixed cosmetic 'jar://' -> 'jar:' comment.

Regression test extended: file:// + loopback + IPv6 ::1 + CGNAT + ULA + 0.0.0.0 +
169.254.170.2 all return empty. ./mvnw test -pl :dotcms-core -Dtest=SstiGetUrlReproTest -> BUILD SUCCESS.

Deferred to dotCMS/private-issues#668: TOCTOU/DNS-rebinding (pin connection to the
validated IP) — needs client-level IP pinning CircuitBreakerUrl doesn't expose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mbiuki

mbiuki commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Pushed 7a4db6e622 addressing all review feedback:

@wezell / @rsh1k — reuse the shared path: getURL now fetches via CircuitBreakerUrl (the client $import uses), replacing the hand-rolled URLConnection. HTTP-only, so file:// is gone; circuit breaker + timeout + IPUtils gate + redirects-disabled come for free.

Automated review items:

  • Multi-address bypass — now resolves with getAllByName and rejects if any A/AAAA record is non-routable.
  • IPv6 ULA (fd00::/7) + CGNAT (100.64/10) — added explicit checks in a new isNonRoutable() helper (the JDK isXxx() predicates miss both).
  • Cosmetic jar://jar: — fixed.
  • TOCTOU / DNS-rebinding — deferred to dotCMS/private-issues#668. Closing it means pinning the connection to the already-validated InetAddress, which CircuitBreakerUrl doesn't expose; it's a client-level change better done once for all consumers. Noted as non-blocking in the review.
  • ℹ️ URL in security log — left as-is (intentional audit signal; no credentials involved).

Regression test extended to assert file://, IPv6 ::1, CGNAT, ULA, 0.0.0.0, and 169.254.170.2 all return empty — ./mvnw test -pl :dotcms-core -Dtest=SstiGetUrlReproTest → BUILD SUCCESS.

@wezell — ready for another look when you have a moment.

🤖 Generated with Claude Code

@mbiuki
mbiuki requested a review from wezell August 8, 2026 13:00

@wezell wezell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good, thanks Mehdi!

@rsh1k
rsh1k enabled auto-merge August 10, 2026 12:43
@rsh1k
rsh1k added this pull request to the merge queue Aug 10, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 10, 2026
@wezell
wezell added this pull request to the merge queue Aug 11, 2026
Any commits made after this event will not be merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Team : Security Issues related to security and privacy

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

4 participants