sec: harden UtilMethods.getURL — restrict to http(s) and block non-routable hosts - #36969
sec: harden UtilMethods.getURL — restrict to http(s) and block non-routable hosts#36969mbiuki wants to merge 5 commits into
Conversation
…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>
|
Claude finished @mbiuki's task in 4m 0s —— View job Review: harden
|
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
|
CI results (head
The MainSuite 2a failure is The security change itself is verified: 🤖 Generated with Claude Code |
| System.setProperty("sun.net.client.defaultConnectTimeout","10000"); | ||
| java.net.URL pointer = new java.net.URL(URI); | ||
|
|
||
| // Security: this method is reachable from the Velocity template context as |
There was a problem hiding this comment.
I would just call CircuitBreakerUrl....toString which already has all this hardening.
There was a problem hiding this comment.
I agree to this. $import already goes through CircuitBreakerUrl, and $xmltool gates on
IPUtils. getURL is the odd one out, so it should use the shared path too.
But IPUtils.isIpPrivateSubnet is weaker than the check in this PR.
Its default list (127.0.0.1/32, 10/8, 172.16/12, 192.168/16, 169.254.169.254/32) misses
127.0.0.2+ (it's /32, not /8), 0.0.0.0, all of IPv6 (SubnetUtils throws on v6 and
isIpInCIDR swallows it, so http://[::1]:8080/ passes), and link-local beyond
169.254.169.254 — e.g. 169.254.170.2, the ECS credentials endpoint.
There was a problem hiding this comment.
Done — getURL now fetches through CircuitBreakerUrl.builder()…doString() (same shared path $import/ImportTool uses): circuit breaker, timeout, IPUtils private-subnet gate, redirects disabled. Being HTTP-only it also drops the file:// read path. Pushed in 7a4db6e622.
There was a problem hiding this comment.
Agreed on both points, and thanks for the precise gap list. Routed getURL through CircuitBreakerUrl — but kept a strong pre-connect host guard exactly because IPUtils.isIpPrivateSubnet is weaker: it now resolves via getAllByName and rejects if any address is non-routable (multi-record bypass), using an isNonRoutable() helper that covers what you listed — full 127/8, 0.0.0.0, all of 169.254/16 (incl. 169.254.170.2), and IPv6 (::1, fe80::/10, plus fd00::/7 ULA and 100.64/10 CGNAT). Verified: file://, [::1], 100.64.0.1, [fd00::1], 0.0.0.0, 169.254.170.2 all return empty in the regression test. 7a4db6e622.
Strengthening IPUtils itself (IPv4 default list + IPv6 support so $import/$xmltool benefit) is worth doing but has wider blast radius — filed as a follow-up on dotCMS/private-issues#668 rather than widening this PR.
…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>
|
Pushed @wezell / @rsh1k — reuse the shared path: Automated review items:
Regression test extended to assert @wezell — ready for another look when you have a moment. 🤖 Generated with Claude Code |
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
getURLself-defend:http/httpsschemes (blocksfile:,jar:,ftp:,gopher:, …).SecurityLogger.Scope / compatibility:
getURLhas no callers in the Java source or in any shipped.vtl, so there is no functional impact.$UtilMethodsstays in the template context and its other helpers (isSet, date/HTML utilities used by ~24 bundled templates) are unchanged.Regression test
SstiGetUrlReproTestguards the fix — assertsfile://read returns empty and a loopback request is never made. Verified locally: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