Skip to content

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

Merged
wezell merged 6 commits into
mainfrom
sec/harden-utilmethods-geturl-668
Aug 11, 2026
Merged

sec: harden UtilMethods.getURL — restrict to http(s) and block non-routable hosts#36969
wezell merged 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
@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
Merged via the queue into main with commit d1f3f98 Aug 11, 2026
67 checks passed
@wezell
wezell deleted the sec/harden-utilmethods-geturl-668 branch August 11, 2026 12:47
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