GitHub Security Advisory: GHSA-w59f-67xm-rxx7
Package: froxlor/froxlor (COMPOSER)
Severity: CRITICAL (CVSS 9.9)
CWE: CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')
Vulnerable versions: <= 2.3.5
Patched in: 2.3.6

Summary: Froxlor has Local File Inclusion via path traversal in API `def_language` parameter leads to Remote Code Execution

Details:
## Summary

The Froxlor API endpoint `Customers.update` (and `Admins.update`) does not validate the `def_language` parameter against the list of available language files. An authenticated customer can set `def_language` to a path traversal payload (e.g., `../../../../../var/customers/webs/customer1/evil`), which is stored in the database. On subsequent requests, `Language::loadLanguage()` constructs a file path using this value and executes it via `require`, achieving arbitrary PHP code execution as the web server user.

## Details

**Root cause:** The API and web UI have inconsistent validation for the `def_language` parameter.

The **web UI** (`customer_index.php:261`, `admin_index.php:265`) correctly validates `def_language` against `Language::getLanguages()`, which scans the `lng/` directory for actual language files:

```php
// customer_index.php:260-265
$def_language = Validate::validate(Request::post('def_language'), 'default language');
if (isset($languages[$def_language])) {
    Customers::getLocal($userinfo, [
        'id' => $userinfo['customerid'],
        'def_language' => $def_language
    ])->update();
```

The **API** (`Customers.php:1207`, `Admins.php:600`) only runs `Validate::validate()` with the default regex `/^[^\r\n\t\f\0]*$/D`, which permits path traversal sequences:

```php
// Customers.php:1167-1172 (customer branch)
} else {
    // allowed parameters
    $def_language = $this->getParam('def_language', true, $result['def_language']);
    ...
}
// Customers.php:1207 - validation (shared by admin and customer paths)
$def_language = Validate::validate($def_language, 'default language', '', '', [], true);
```

The tainted value is stored in the `panel_customers` (or `panel_admins`) table. On every subsequent request, it is loaded and used in two paths:

**API path** (`ApiCommand.php:218-222`):
```php
private function initLang()
{
    Language::setLanguage(Settings::Get('panel.standardlanguage'));
    if ($this->getUserDetail('language') !== null && isset(Language::getLanguages()[$this->getUserDetail('language')])) {
        Language::setLanguage($this->getUserDetail('language'));
    } elseif ($this->getUserDetail('def_language') !== null) {
        Language::setLanguage($this->getUserDetail('def_language')); // No validation
    }
}
```

**Web path** (`init.php:180-185`):
```php
if (CurrentUser::hasSession()) {
    if (!empty(CurrentUser::getField('language')) && isset(Language::getLanguages()[CurrentUser::getField('language')])) {
        Language::setLanguage(CurrentUser::getField('language'));
    } else {
        Language::setLanguage(CurrentUser::getField('def_language')); // No validation
    }
}
```

The `language` session field is `null` for API requests and empty on fresh web logins, so both paths fall through to the unvalidated `def_language`.

**File inclusion** (`Language.php:89-98`):
```php
private static function loadLanguage($iso): array
{
    $languageFile = dirname(__DIR__, 2) . sprintf('/lng/%s.lng.php', $