Skip to content

fix(files_external): add SSRF host validation to external storage backend#41581

Open
DeepDiver1975 wants to merge 2 commits into
masterfrom
security/fix-files-external-ssrf
Open

fix(files_external): add SSRF host validation to external storage backend#41581
DeepDiver1975 wants to merge 2 commits into
masterfrom
security/fix-files-external-ssrf

Conversation

@DeepDiver1975

Copy link
Copy Markdown
Member

Summary

  • UserStoragesController (@NoAdminRequired) accepted arbitrary host values and immediately tested the connection, forcing outbound HTTP to any attacker-specified target
  • No IP range checks, no DNS rebinding protection, no blocklist existed anywhere in the validation chain
  • Fix adds validateHostOption() in StoragesController::validate() blocking private ranges, loopback, link-local, and IPv6 equivalents; admin can opt out via files_external_allow_private_address=true

Security Impact

High — SSRF allowing cloud metadata exfiltration and internal network scanning for any authenticated user when user mounting is enabled

Note

This PR touches StoragesController.php — merge before security/fix-files-external-info-disclosure to avoid conflicts.

Test plan

  • Blocked host tests assert HTTP 403 for loopback/private/link-local targets
  • Allowed host tests confirm legitimate external addresses pass through
  • Run make test TEST_PHP_SUITE=apps/files_external

🤖 Generated with Claude Code

…kend

UserStoragesController::create()/update() (@NoAdminRequired) accepted
arbitrary host values for DAV, SMB, and other network backends without
validating against private IP ranges, loopback, or link-local addresses.
With user mounting enabled, authenticated users could force the server
to make HTTP requests to cloud metadata endpoints (169.254.169.254),
localhost services, or internal network hosts.

Add validateHostOption() to StoragesController::validate(), which blocks
RFC-1918 private ranges, loopback (127.x.x.x / ::1), IPv4 link-local
(169.254.x.x), IPv6 link-local (fe80::/10), and ULA (fc00::/7).
Admin escape-hatch: files_external_allow_private_address=true in config.

Signed-off-by: Thomas Müller <thomas.mueller@owncloud.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@update-docs

update-docs Bot commented Jun 5, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>

@DeepDiver1975 DeepDiver1975 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code Review — fix(files_external): add SSRF host validation to external storage backend configuration

Overview: Adds validateHostOption() to StoragesController that checks the host backend option against private/loopback/link-local ranges before a storage config is saved. Admin opt-out via files_external_allow_private_address config key.

Correctness

The validation logic covers:

  • Loopback (localhost, localhost.localdomain, 127.0.0.0/8, ::1) ✅
  • RFC-1918 private ranges via FILTER_FLAG_NO_PRIV_RANGE
  • IPv4 link-local 169.254.0.0/16 (separate check since PHP's flag doesn't cover it) ✅
  • IPv6 link-local fe80::/10

One gap in IPv6 link-local detection: The check uses:

stripos($ip, 'fe80:') === 0 || stripos($ip, 'fe81:') === 0 || preg_match('/^fe[89ab][0-9a-f]:/i', $ip)

fe80::/10 covers fe80:: through febf::. The regex covers fe80feb9 and febafebf correctly, but only prefixes like fe80:, fe81:, etc. An address like fe9a:... would be matched by the regex but not the explicit stripos checks — however since the regex is evaluated via || it would still be caught. The logic is correct but redundant. The two stripos checks are subsumed by the regex and could be removed for clarity. Minor, not blocking.

$config = null default parameter in constructor signatures: this allows existing callers without the new parameter to continue working, falling back to \OC::$server->getConfig(). This is a pragmatic backwards-compatibility choice for the controller injection. ✅

Tests

The test suite in StoragesControllerTest is comprehensive:

  • blockedHostProvider covers IPv4 loopback, localhost, all RFC-1918 ranges, IPv4 link-local (including the AWS metadata endpoint), port suffixes, scheme-prefixed variants, and path-suffixed addresses
  • testCreateBlockedSsrfHost and testUpdateBlockedSsrfHost both assert STATUS_FORBIDDEN
  • testCreateAllowedPublicHost asserts STATUS_CREATED for example.com
  • testCreateBlockedSsrfHostAllowedByAdmin verifies the opt-out config key allows private addresses through ✅

Summary

Aspect Assessment
Security fix ✅ SSRF via host option blocked at controller layer
Admin escape hatch files_external_allow_private_address opt-out
IPv6 link-local Minor: redundant checks, logic is correct
Tests ✅ Comprehensive with data providers

Verdict: Ready to merge.

@dj4oC dj4oC left a comment

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.

Adds hostname-resolving SSRF validation (gethostbyname() + private/link-local range checks) to StoragesController::validate() for external storage backend creation/updates, with an admin override (files_external_allow_private_address).

Findings

  • Security — CWE-918 SSRF, OWASP A10:2021: materially better than the sibling PR #41576 because it actually attempts hostname resolution before range-checking, and it's correctly gated (only fires on create/update of new configs, not retroactively on existing ones — confirmed via call-site grep on GlobalStoragesController/UserStoragesController), with a proper admin escape hatch. Residual gaps: gethostbyname() only resolves A/IPv4 records, so an IPv6-only hostname pointing at an internal address (::1, fe80::, fc00::/7) falls through to "allow" in isBlockedAddress() when resolution doesn't produce a valid IP string; also no redirect/connect-time re-validation (TOCTOU/DNS-rebinding class gap), and the same decimal/octal IP-literal encoding bypass as #41576, though less likely to matter here since resolution is attempted first.
  • Stability: no findings — nullable IConfig $config = null constructor addition is backward compatible; doesn't retroactively break existing saved storage configs.
  • Performance: no findings — gethostbyname() only runs on the admin create/update path, not on file-serving requests. Minor: unbounded DNS timeout could make a single admin request hang on an unresponsive resolver; not request-path-affecting, non-blocking.
  • Test coverage: solid for IPv4/hostname/admin-override cases (tests exercise the real controller endpoints, not just the validator function in isolation); no IPv6 test cases exist despite isBlockedAddress() having explicit IPv6 logic; some tests make live DNS calls to example.com, which is a test-isolation smell worth cleaning up.

Verdict

Commenting — clearly the more thorough of the two SSRF fixes in this batch and safe to merge as an incremental improvement (contained blast radius, admin override, no regression to existing configs), but the IPv6-only bypass should be tracked as a fast follow-up rather than treated as fully closed.

🤖 Automated review by Claude Code (security · stability · performance · coverage)


Generated by Claude Code

@jvillafanez jvillafanez 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.

I think the host check should be move somewhere else so it can be reused in other places. It might be better to inject a HostValidator instead of the IConfig in the storage controller.

IGlobalStoragesService $globalStoragesService,
ILogger $logger
ILogger $logger,
IConfig $config = null

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.

Default value shouldn't be needed I think. Also for the other controllers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants