VYPR
Medium severity6.9NVD Advisory· Published Jun 8, 2026· Updated Jun 8, 2026

Poweradmin: CSV Injection in log export endpoints allows formula execution in spreadsheet applications

CVE-2026-47693

Description

Description:

Summary

Poweradmin v4.4.0 is vulnerable to CSV Injection (Formula Injection) in its log export functionality. User-controlled data — specifically the username field — is written to exported CSV files without sanitizing formula trigger characters (=, +, -, @). When an administrator exports activity logs and opens the resulting CSV in a spreadsheet application (Microsoft Excel, LibreOffice Calc, Google Sheets), any formula stored in a username is executed by the application. This can be used for phishing attacks against administrators or data exfiltration.

Details

The vulnerability exists in all four log export controllers:

  • lib/Application/Controller/ListLogUsersController.php (lines 188, 194)
  • lib/Application/Controller/ListLogZonesController.php
  • lib/Application/Controller/ListLogGroupsController.php
  • lib/Application/Controller/ListLogApiController.php

These controllers export database rows via fputcsv() without applying any formula injection countermeasures. The user column contains the username of the actor who performed the operation, and the username column (in user logs) contains the username of the affected account. Both fields are written verbatim to the CSV output.

A username such as =1+1 is written without CSV enclosure quotes (because it contains no commas or quotes), so spreadsheet applications treat it directly as a formula. A username containing commas or quotes (e.g. =HYPERLINK("http://attacker.com","Click here")) is enclosed in CSV quotes with internal quotes doubled, but spreadsheet applications still evaluate the cell value as a formula since it begins with =.

Additionally, PHP deprecation warnings are emitted directly into the HTTP response body before CSV headers, exposing internal file paths (e.g. /app/lib/Application/Controller/ListLogUsersController.php) — a secondary information disclosure issue (CWE-209). This also corrupts the CSV file when PHP error reporting is enabled.

PoC

Prerequisites: An account with user_add_new permission (administrator role).

Steps to reproduce:

1. Log in as administrator. 2. Navigate to Add User and create an account with: - Username: =HYPERLINK("http://attacker.com","Confirm Identity") - Any valid email and password 3. Log out, then log in with the newly created account to generate a log entry. 4. Log back in as administrator. 5. Navigate to /users/logs and click Export CSV. 6. Open the downloaded CSV file in Microsoft Excel or LibreOffice Calc.

Result: Excel renders a clickable hyperlink labeled "Confirm Identity" pointing to http://attacker.com in the user column of the log entry. With the simpler username =1+1, the cell displays 2 instead of the literal text, confirming formula execution.

Confirmed on Poweradmin v4.4.0 (Docker image poweradmin/poweradmin:latest).

Impact

This is a CSV Injection vulnerability (CWE-1236). It affects any administrator who exports activity logs to CSV and opens the file in a spreadsheet application.

Attack scenarios:

  • Phishing: A malicious actor with the ability to create user accounts sets a formula username that renders as a convincing link in the exported report, tricking a higher-privileged administrator into clicking it.
  • Data exfiltration: Using =IMPORTXML() in Google Sheets or similar, adjacent cell data (log contents) can be sent to an attacker-controlled server silently when the sheet is opened.

Affected products

1

Patches

2
8842b17ce9fd

fix(exports): quote leading special characters in CSV cells

https://github.com/poweradmin/poweradminEdmondas GirkantasMay 19, 2026Fixed in 4.2.4via ghsa-release-walk
5 files changed · +177 1
  • lib/Infrastructure/Utility/CsvFormulaEscaper.php+48 0 added
    @@ -0,0 +1,48 @@
    +<?php
    +
    +/*  Poweradmin, a friendly web-based admin tool for PowerDNS.
    + *  See <https://www.poweradmin.org> for more details.
    + *
    + *  Copyright 2007-2010 Rejo Zenger <rejo@zenger.nl>
    + *  Copyright 2010-2026 Poweradmin Development Team
    + *
    + *  This program is free software: you can redistribute it and/or modify
    + *  it under the terms of the GNU General Public License as published by
    + *  the Free Software Foundation, either version 3 of the License, or
    + *  (at your option) any later version.
    + *
    + *  This program is distributed in the hope that it will be useful,
    + *  but WITHOUT ANY WARRANTY; without even the implied warranty of
    + *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    + *  GNU General Public License for more details.
    + *
    + *  You should have received a copy of the GNU General Public License
    + *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
    + */
    +
    +namespace Poweradmin\Infrastructure\Utility;
    +
    +class CsvFormulaEscaper
    +{
    +    private const FORMULA_TRIGGERS = ['=', '+', '-', '@', "\t", "\r", "\n"];
    +
    +    public static function escape(mixed $value): mixed
    +    {
    +        if (!is_string($value) || $value === '') {
    +            return $value;
    +        }
    +
    +        // Strip only ASCII spaces - tab/CR/LF must remain to count as direct triggers.
    +        $trimmed = ltrim($value, ' ');
    +        if ($trimmed === '' || !in_array($trimmed[0], self::FORMULA_TRIGGERS, true)) {
    +            return $value;
    +        }
    +
    +        return "'" . $value;
    +    }
    +
    +    public static function escapeRow(array $row): array
    +    {
    +        return array_map([self::class, 'escape'], $row);
    +    }
    +}
    
  • lib/Module/CsvExport/Controller/CsvExportController.php+2 1 modified
    @@ -29,6 +29,7 @@
     use Poweradmin\Domain\Service\UserContextService;
     use Poweradmin\Infrastructure\Repository\DbUserRepository;
     use Poweradmin\Infrastructure\Repository\DbZoneRepository;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class CsvExportController extends BaseController
     {
    @@ -109,7 +110,7 @@ public function run(): void
                     $row[] = $record['comment'] ?? '';
                 }
     
    -            fputcsv($output, $row, ',', '"', '');
    +            fputcsv($output, CsvFormulaEscaper::escapeRow($row), ',', '"', '');
             }
     
             fclose($output);
    
  • tests/unit/Infrastructure/Utility/CsvFormulaEscaperTest.php+125 0 added
    @@ -0,0 +1,125 @@
    +<?php
    +
    +/*  Poweradmin, a friendly web-based admin tool for PowerDNS.
    + *  See <https://www.poweradmin.org> for more details.
    + *
    + *  Copyright 2007-2010 Rejo Zenger <rejo@zenger.nl>
    + *  Copyright 2010-2026 Poweradmin Development Team
    + *
    + *  This program is free software: you can redistribute it and/or modify
    + *  it under the terms of the GNU General Public License as published by
    + *  the Free Software Foundation, either version 3 of the License, or
    + *  (at your option) any later version.
    + *
    + *  This program is distributed in the hope that it will be useful,
    + *  but WITHOUT ANY WARRANTY; without even the implied warranty of
    + *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    + *  GNU General Public License for more details.
    + *
    + *  You should have received a copy of the GNU General Public License
    + *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
    + */
    +
    +namespace Poweradmin\Tests\Unit\Infrastructure\Utility;
    +
    +use PHPUnit\Framework\TestCase;
    +use PHPUnit\Framework\Attributes\DataProvider;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
    +
    +class CsvFormulaEscaperTest extends TestCase
    +{
    +    public static function triggerCharacters(): array
    +    {
    +        return [
    +            'equals'          => ['='],
    +            'plus'            => ['+'],
    +            'minus'           => ['-'],
    +            'at'              => ['@'],
    +            'tab'             => ["\t"],
    +            'carriage return' => ["\r"],
    +            'line feed'       => ["\n"],
    +        ];
    +    }
    +
    +    #[DataProvider('triggerCharacters')]
    +    public function testValueStartingWithTriggerGetsQuotePrefix(string $trigger): void
    +    {
    +        $value = $trigger . 'abc';
    +        $this->assertSame("'" . $value, CsvFormulaEscaper::escape($value));
    +    }
    +
    +    #[DataProvider('triggerCharacters')]
    +    public function testTriggerAfterLeadingSpacesIsStillEscaped(string $trigger): void
    +    {
    +        // tab/CR/LF would be consumed by ltrim if spaces were stripped greedily;
    +        // they are still escaped because they remain the first character.
    +        $value = '   ' . $trigger . 'abc';
    +        $this->assertSame("'" . $value, CsvFormulaEscaper::escape($value));
    +    }
    +
    +    public function testPlainStringsAreUnchanged(): void
    +    {
    +        $this->assertSame('admin', CsvFormulaEscaper::escape('admin'));
    +        $this->assertSame('user@example.com', CsvFormulaEscaper::escape('user@example.com'));
    +        $this->assertSame('example.com.', CsvFormulaEscaper::escape('example.com.'));
    +        $this->assertSame('192.0.2.1', CsvFormulaEscaper::escape('192.0.2.1'));
    +        $this->assertSame(' leading space ok', CsvFormulaEscaper::escape(' leading space ok'));
    +    }
    +
    +    public function testEmptyStringIsUnchanged(): void
    +    {
    +        $this->assertSame('', CsvFormulaEscaper::escape(''));
    +    }
    +
    +    public function testWhitespaceOnlyStringIsUnchanged(): void
    +    {
    +        $this->assertSame('   ', CsvFormulaEscaper::escape('   '));
    +    }
    +
    +    public function testNonStringScalarsArePassedThrough(): void
    +    {
    +        $this->assertSame(42, CsvFormulaEscaper::escape(42));
    +        $this->assertSame(3.14, CsvFormulaEscaper::escape(3.14));
    +        $this->assertSame(true, CsvFormulaEscaper::escape(true));
    +        $this->assertSame(null, CsvFormulaEscaper::escape(null));
    +    }
    +
    +    public function testEscapeRowAppliesElementWise(): void
    +    {
    +        $row = ['admin', '=abc', 'note', '@abc', 'plain'];
    +        $expected = ['admin', "'=abc", 'note', "'@abc", 'plain'];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapeRowPreservesKeys(): void
    +    {
    +        $row = ['username' => '=abc', 'email' => 'a@b.test'];
    +        $expected = ['username' => "'=abc", 'email' => 'a@b.test'];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapeRowPassesNonStringsThrough(): void
    +    {
    +        $row = ['id' => 5, 'active' => true, 'name' => '=abc', 'comment' => null];
    +        $expected = ['id' => 5, 'active' => true, 'name' => "'=abc", 'comment' => null];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapedValueSurvivesFputcsvRoundTrip(): void
    +    {
    +        $row = CsvFormulaEscaper::escapeRow(['=abc', 'plain']);
    +        $stream = fopen('php://memory', 'r+');
    +        fputcsv($stream, $row);
    +        rewind($stream);
    +        $written = stream_get_contents($stream);
    +        fclose($stream);
    +
    +        // The single-quote prefix must survive fputcsv encoding so the
    +        // importing application treats the cell as text, not a formula.
    +        $this->assertStringContainsString("'=abc", $written);
    +        $this->assertStringContainsString('plain', $written);
    +    }
    +}
    
  • vendor/composer/autoload_classmap.php+1 0 modified
    @@ -1271,6 +1271,7 @@
         'Poweradmin\\Infrastructure\\Service\\RedirectService' => $baseDir . '/lib/Infrastructure/Service/RedirectService.php',
         'Poweradmin\\Infrastructure\\Service\\SessionAuthenticator' => $baseDir . '/lib/Infrastructure/Service/SessionAuthenticator.php',
         'Poweradmin\\Infrastructure\\Service\\StyleManager' => $baseDir . '/lib/Infrastructure/Service/StyleManager.php',
    +    'Poweradmin\\Infrastructure\\Utility\\CsvFormulaEscaper' => $baseDir . '/lib/Infrastructure/Utility/CsvFormulaEscaper.php',
         'Poweradmin\\Infrastructure\\Utility\\DependencyCheck' => $baseDir . '/lib/Infrastructure/Utility/DependencyCheck.php',
         'Poweradmin\\Infrastructure\\Utility\\IpAddressRetriever' => $baseDir . '/lib/Infrastructure/Utility/IpAddressRetriever.php',
         'Poweradmin\\Infrastructure\\Utility\\LanguageCode' => $baseDir . '/lib/Infrastructure/Utility/LanguageCode.php',
    
  • vendor/composer/autoload_static.php+1 0 modified
    @@ -1666,6 +1666,7 @@ class ComposerStaticInit69e2902df91bc724db96bb81f54b17f4
             'Poweradmin\\Infrastructure\\Service\\RedirectService' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/RedirectService.php',
             'Poweradmin\\Infrastructure\\Service\\SessionAuthenticator' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/SessionAuthenticator.php',
             'Poweradmin\\Infrastructure\\Service\\StyleManager' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/StyleManager.php',
    +        'Poweradmin\\Infrastructure\\Utility\\CsvFormulaEscaper' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/CsvFormulaEscaper.php',
             'Poweradmin\\Infrastructure\\Utility\\DependencyCheck' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/DependencyCheck.php',
             'Poweradmin\\Infrastructure\\Utility\\IpAddressRetriever' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/IpAddressRetriever.php',
             'Poweradmin\\Infrastructure\\Utility\\LanguageCode' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/LanguageCode.php',
    
e36aa4bba198

fix(exports): quote leading special characters in CSV cells

https://github.com/poweradmin/poweradminEdmondas GirkantasMay 19, 2026Fixed in 4.3.3via ghsa-release-walk
9 files changed · +193 9
  • lib/Application/Controller/ListLogApiController.php+3 2 modified
    @@ -29,6 +29,7 @@
     use Poweradmin\Infrastructure\Configuration\ConfigurationManager;
     use Poweradmin\Infrastructure\Logger\DbApiLogger;
     use Poweradmin\Infrastructure\Service\HttpPaginationParameters;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class ListLogApiController extends BaseController
     {
    @@ -175,13 +176,13 @@ private function exportLogs(array $filters, string $format): void
                         $allKeys = array_merge($allKeys, array_keys($row));
                     }
                     $allKeys = array_unique($allKeys);
    -                fputcsv($output, $allKeys);
    +                fputcsv($output, CsvFormulaEscaper::escapeRow($allKeys));
                     foreach ($parsed as $row) {
                         $csvRow = [];
                         foreach ($allKeys as $key) {
                             $csvRow[] = $row[$key] ?? '';
                         }
    -                    fputcsv($output, $csvRow);
    +                    fputcsv($output, CsvFormulaEscaper::escapeRow($csvRow));
                     }
                 }
                 fclose($output);
    
  • lib/Application/Controller/ListLogGroupsController.php+3 2 modified
    @@ -38,6 +38,7 @@
     use Poweradmin\Infrastructure\Configuration\ConfigurationManager;
     use Poweradmin\Infrastructure\Logger\DbGroupLogger;
     use Poweradmin\Infrastructure\Service\HttpPaginationParameters;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class ListLogGroupsController extends BaseController
     {
    @@ -190,13 +191,13 @@ private function exportLogs(array $filters, string $format): void
                         $allKeys = array_merge($allKeys, array_keys($row));
                     }
                     $allKeys = array_unique($allKeys);
    -                fputcsv($output, $allKeys);
    +                fputcsv($output, CsvFormulaEscaper::escapeRow($allKeys));
                     foreach ($parsed as $row) {
                         $csvRow = [];
                         foreach ($allKeys as $key) {
                             $csvRow[] = $row[$key] ?? '';
                         }
    -                    fputcsv($output, $csvRow);
    +                    fputcsv($output, CsvFormulaEscaper::escapeRow($csvRow));
                     }
                 }
                 fclose($output);
    
  • lib/Application/Controller/ListLogUsersController.php+3 2 modified
    @@ -38,6 +38,7 @@
     use Poweradmin\Infrastructure\Configuration\ConfigurationManager;
     use Poweradmin\Infrastructure\Logger\DbUserLogger;
     use Poweradmin\Infrastructure\Service\HttpPaginationParameters;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class ListLogUsersController extends BaseController
     {
    @@ -185,13 +186,13 @@ private function exportLogs(array $filters, string $format): void
                         $allKeys = array_merge($allKeys, array_keys($row));
                     }
                     $allKeys = array_unique($allKeys);
    -                fputcsv($output, $allKeys);
    +                fputcsv($output, CsvFormulaEscaper::escapeRow($allKeys));
                     foreach ($parsed as $row) {
                         $csvRow = [];
                         foreach ($allKeys as $key) {
                             $csvRow[] = $row[$key] ?? '';
                         }
    -                    fputcsv($output, $csvRow);
    +                    fputcsv($output, CsvFormulaEscaper::escapeRow($csvRow));
                     }
                 }
                 fclose($output);
    
  • lib/Application/Controller/ListLogZonesController.php+3 2 modified
    @@ -39,6 +39,7 @@
     use Poweradmin\Infrastructure\Configuration\ConfigurationManager;
     use Poweradmin\Infrastructure\Logger\DbZoneLogger;
     use Poweradmin\Infrastructure\Service\HttpPaginationParameters;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class ListLogZonesController extends BaseController
     {
    @@ -171,9 +172,9 @@ private function exportLogs(array $filters, string $format): void
                 header('Content-Disposition: attachment; filename="zone-logs-' . date('Y-m-d') . '.csv"');
                 $output = fopen('php://output', 'w');
                 if (!empty($parsed)) {
    -                fputcsv($output, array_keys($parsed[0]));
    +                fputcsv($output, CsvFormulaEscaper::escapeRow(array_keys($parsed[0])));
                     foreach ($parsed as $row) {
    -                    fputcsv($output, $row);
    +                    fputcsv($output, CsvFormulaEscaper::escapeRow($row));
                     }
                 }
                 fclose($output);
    
  • lib/Infrastructure/Utility/CsvFormulaEscaper.php+48 0 added
    @@ -0,0 +1,48 @@
    +<?php
    +
    +/*  Poweradmin, a friendly web-based admin tool for PowerDNS.
    + *  See <https://www.poweradmin.org> for more details.
    + *
    + *  Copyright 2007-2010 Rejo Zenger <rejo@zenger.nl>
    + *  Copyright 2010-2026 Poweradmin Development Team
    + *
    + *  This program is free software: you can redistribute it and/or modify
    + *  it under the terms of the GNU General Public License as published by
    + *  the Free Software Foundation, either version 3 of the License, or
    + *  (at your option) any later version.
    + *
    + *  This program is distributed in the hope that it will be useful,
    + *  but WITHOUT ANY WARRANTY; without even the implied warranty of
    + *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    + *  GNU General Public License for more details.
    + *
    + *  You should have received a copy of the GNU General Public License
    + *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
    + */
    +
    +namespace Poweradmin\Infrastructure\Utility;
    +
    +class CsvFormulaEscaper
    +{
    +    private const FORMULA_TRIGGERS = ['=', '+', '-', '@', "\t", "\r", "\n"];
    +
    +    public static function escape(mixed $value): mixed
    +    {
    +        if (!is_string($value) || $value === '') {
    +            return $value;
    +        }
    +
    +        // Strip only ASCII spaces - tab/CR/LF must remain to count as direct triggers.
    +        $trimmed = ltrim($value, ' ');
    +        if ($trimmed === '' || !in_array($trimmed[0], self::FORMULA_TRIGGERS, true)) {
    +            return $value;
    +        }
    +
    +        return "'" . $value;
    +    }
    +
    +    public static function escapeRow(array $row): array
    +    {
    +        return array_map([self::class, 'escape'], $row);
    +    }
    +}
    
  • lib/Module/CsvExport/Controller/CsvExportController.php+2 1 modified
    @@ -28,6 +28,7 @@
     use Poweradmin\Domain\Service\PermissionService;
     use Poweradmin\Domain\Service\UserContextService;
     use Poweradmin\Infrastructure\Repository\DbUserRepository;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
     
     class CsvExportController extends BaseController
     {
    @@ -108,7 +109,7 @@ public function run(): void
                     $row[] = $record['comment'] ?? '';
                 }
     
    -            fputcsv($output, $row, ',', '"', '');
    +            fputcsv($output, CsvFormulaEscaper::escapeRow($row), ',', '"', '');
             }
     
             fclose($output);
    
  • tests/unit/Infrastructure/Utility/CsvFormulaEscaperTest.php+125 0 added
    @@ -0,0 +1,125 @@
    +<?php
    +
    +/*  Poweradmin, a friendly web-based admin tool for PowerDNS.
    + *  See <https://www.poweradmin.org> for more details.
    + *
    + *  Copyright 2007-2010 Rejo Zenger <rejo@zenger.nl>
    + *  Copyright 2010-2026 Poweradmin Development Team
    + *
    + *  This program is free software: you can redistribute it and/or modify
    + *  it under the terms of the GNU General Public License as published by
    + *  the Free Software Foundation, either version 3 of the License, or
    + *  (at your option) any later version.
    + *
    + *  This program is distributed in the hope that it will be useful,
    + *  but WITHOUT ANY WARRANTY; without even the implied warranty of
    + *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    + *  GNU General Public License for more details.
    + *
    + *  You should have received a copy of the GNU General Public License
    + *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
    + */
    +
    +namespace Poweradmin\Tests\Unit\Infrastructure\Utility;
    +
    +use PHPUnit\Framework\TestCase;
    +use PHPUnit\Framework\Attributes\DataProvider;
    +use Poweradmin\Infrastructure\Utility\CsvFormulaEscaper;
    +
    +class CsvFormulaEscaperTest extends TestCase
    +{
    +    public static function triggerCharacters(): array
    +    {
    +        return [
    +            'equals'          => ['='],
    +            'plus'            => ['+'],
    +            'minus'           => ['-'],
    +            'at'              => ['@'],
    +            'tab'             => ["\t"],
    +            'carriage return' => ["\r"],
    +            'line feed'       => ["\n"],
    +        ];
    +    }
    +
    +    #[DataProvider('triggerCharacters')]
    +    public function testValueStartingWithTriggerGetsQuotePrefix(string $trigger): void
    +    {
    +        $value = $trigger . 'abc';
    +        $this->assertSame("'" . $value, CsvFormulaEscaper::escape($value));
    +    }
    +
    +    #[DataProvider('triggerCharacters')]
    +    public function testTriggerAfterLeadingSpacesIsStillEscaped(string $trigger): void
    +    {
    +        // tab/CR/LF would be consumed by ltrim if spaces were stripped greedily;
    +        // they are still escaped because they remain the first character.
    +        $value = '   ' . $trigger . 'abc';
    +        $this->assertSame("'" . $value, CsvFormulaEscaper::escape($value));
    +    }
    +
    +    public function testPlainStringsAreUnchanged(): void
    +    {
    +        $this->assertSame('admin', CsvFormulaEscaper::escape('admin'));
    +        $this->assertSame('user@example.com', CsvFormulaEscaper::escape('user@example.com'));
    +        $this->assertSame('example.com.', CsvFormulaEscaper::escape('example.com.'));
    +        $this->assertSame('192.0.2.1', CsvFormulaEscaper::escape('192.0.2.1'));
    +        $this->assertSame(' leading space ok', CsvFormulaEscaper::escape(' leading space ok'));
    +    }
    +
    +    public function testEmptyStringIsUnchanged(): void
    +    {
    +        $this->assertSame('', CsvFormulaEscaper::escape(''));
    +    }
    +
    +    public function testWhitespaceOnlyStringIsUnchanged(): void
    +    {
    +        $this->assertSame('   ', CsvFormulaEscaper::escape('   '));
    +    }
    +
    +    public function testNonStringScalarsArePassedThrough(): void
    +    {
    +        $this->assertSame(42, CsvFormulaEscaper::escape(42));
    +        $this->assertSame(3.14, CsvFormulaEscaper::escape(3.14));
    +        $this->assertSame(true, CsvFormulaEscaper::escape(true));
    +        $this->assertSame(null, CsvFormulaEscaper::escape(null));
    +    }
    +
    +    public function testEscapeRowAppliesElementWise(): void
    +    {
    +        $row = ['admin', '=abc', 'note', '@abc', 'plain'];
    +        $expected = ['admin', "'=abc", 'note', "'@abc", 'plain'];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapeRowPreservesKeys(): void
    +    {
    +        $row = ['username' => '=abc', 'email' => 'a@b.test'];
    +        $expected = ['username' => "'=abc", 'email' => 'a@b.test'];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapeRowPassesNonStringsThrough(): void
    +    {
    +        $row = ['id' => 5, 'active' => true, 'name' => '=abc', 'comment' => null];
    +        $expected = ['id' => 5, 'active' => true, 'name' => "'=abc", 'comment' => null];
    +
    +        $this->assertSame($expected, CsvFormulaEscaper::escapeRow($row));
    +    }
    +
    +    public function testEscapedValueSurvivesFputcsvRoundTrip(): void
    +    {
    +        $row = CsvFormulaEscaper::escapeRow(['=abc', 'plain']);
    +        $stream = fopen('php://memory', 'r+');
    +        fputcsv($stream, $row);
    +        rewind($stream);
    +        $written = stream_get_contents($stream);
    +        fclose($stream);
    +
    +        // The single-quote prefix must survive fputcsv encoding so the
    +        // importing application treats the cell as text, not a formula.
    +        $this->assertStringContainsString("'=abc", $written);
    +        $this->assertStringContainsString('plain', $written);
    +    }
    +}
    
  • vendor/composer/autoload_classmap.php+3 0 modified
    @@ -1212,12 +1212,14 @@
         'Poweradmin\\Domain\\Service\\RecordMatchingService' => $baseDir . '/lib/Domain/Service/RecordMatchingService.php',
         'Poweradmin\\Domain\\Service\\RecordTypeService' => $baseDir . '/lib/Domain/Service/RecordTypeService.php',
         'Poweradmin\\Domain\\Service\\ReverseRecordCreator' => $baseDir . '/lib/Domain/Service/ReverseRecordCreator.php',
    +    'Poweradmin\\Domain\\Service\\ReverseTtlResolver' => $baseDir . '/lib/Domain/Service/ReverseTtlResolver.php',
         'Poweradmin\\Domain\\Service\\SessionService' => $baseDir . '/lib/Domain/Service/SessionService.php',
         'Poweradmin\\Domain\\Service\\UserAgreementService' => $baseDir . '/lib/Domain/Service/UserAgreementService.php',
         'Poweradmin\\Domain\\Service\\UserAvatarService' => $baseDir . '/lib/Domain/Service/UserAvatarService.php',
         'Poweradmin\\Domain\\Service\\UserContextService' => $baseDir . '/lib/Domain/Service/UserContextService.php',
         'Poweradmin\\Domain\\Service\\UserManagementService' => $baseDir . '/lib/Domain/Service/UserManagementService.php',
         'Poweradmin\\Domain\\Service\\UserPreferenceService' => $baseDir . '/lib/Domain/Service/UserPreferenceService.php',
    +    'Poweradmin\\Domain\\Service\\UserTimezoneService' => $baseDir . '/lib/Domain/Service/UserTimezoneService.php',
         'Poweradmin\\Domain\\Service\\Validation\\ValidationResult' => $baseDir . '/lib/Domain/Service/Validation/ValidationResult.php',
         'Poweradmin\\Domain\\Service\\Validator' => $baseDir . '/lib/Domain/Service/Validator.php',
         'Poweradmin\\Domain\\Service\\ZoneCountService' => $baseDir . '/lib/Domain/Service/ZoneCountService.php',
    @@ -1307,6 +1309,7 @@
         'Poweradmin\\Infrastructure\\Service\\SessionAuthenticator' => $baseDir . '/lib/Infrastructure/Service/SessionAuthenticator.php',
         'Poweradmin\\Infrastructure\\Service\\SqlDnsBackendProvider' => $baseDir . '/lib/Infrastructure/Service/SqlDnsBackendProvider.php',
         'Poweradmin\\Infrastructure\\Service\\StyleManager' => $baseDir . '/lib/Infrastructure/Service/StyleManager.php',
    +    'Poweradmin\\Infrastructure\\Utility\\CsvFormulaEscaper' => $baseDir . '/lib/Infrastructure/Utility/CsvFormulaEscaper.php',
         'Poweradmin\\Infrastructure\\Utility\\DependencyCheck' => $baseDir . '/lib/Infrastructure/Utility/DependencyCheck.php',
         'Poweradmin\\Infrastructure\\Utility\\IpAddressRetriever' => $baseDir . '/lib/Infrastructure/Utility/IpAddressRetriever.php',
         'Poweradmin\\Infrastructure\\Utility\\LanguageCode' => $baseDir . '/lib/Infrastructure/Utility/LanguageCode.php',
    
  • vendor/composer/autoload_static.php+3 0 modified
    @@ -1607,12 +1607,14 @@ class ComposerStaticInit69e2902df91bc724db96bb81f54b17f4
             'Poweradmin\\Domain\\Service\\RecordMatchingService' => __DIR__ . '/../..' . '/lib/Domain/Service/RecordMatchingService.php',
             'Poweradmin\\Domain\\Service\\RecordTypeService' => __DIR__ . '/../..' . '/lib/Domain/Service/RecordTypeService.php',
             'Poweradmin\\Domain\\Service\\ReverseRecordCreator' => __DIR__ . '/../..' . '/lib/Domain/Service/ReverseRecordCreator.php',
    +        'Poweradmin\\Domain\\Service\\ReverseTtlResolver' => __DIR__ . '/../..' . '/lib/Domain/Service/ReverseTtlResolver.php',
             'Poweradmin\\Domain\\Service\\SessionService' => __DIR__ . '/../..' . '/lib/Domain/Service/SessionService.php',
             'Poweradmin\\Domain\\Service\\UserAgreementService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserAgreementService.php',
             'Poweradmin\\Domain\\Service\\UserAvatarService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserAvatarService.php',
             'Poweradmin\\Domain\\Service\\UserContextService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserContextService.php',
             'Poweradmin\\Domain\\Service\\UserManagementService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserManagementService.php',
             'Poweradmin\\Domain\\Service\\UserPreferenceService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserPreferenceService.php',
    +        'Poweradmin\\Domain\\Service\\UserTimezoneService' => __DIR__ . '/../..' . '/lib/Domain/Service/UserTimezoneService.php',
             'Poweradmin\\Domain\\Service\\Validation\\ValidationResult' => __DIR__ . '/../..' . '/lib/Domain/Service/Validation/ValidationResult.php',
             'Poweradmin\\Domain\\Service\\Validator' => __DIR__ . '/../..' . '/lib/Domain/Service/Validator.php',
             'Poweradmin\\Domain\\Service\\ZoneCountService' => __DIR__ . '/../..' . '/lib/Domain/Service/ZoneCountService.php',
    @@ -1702,6 +1704,7 @@ class ComposerStaticInit69e2902df91bc724db96bb81f54b17f4
             'Poweradmin\\Infrastructure\\Service\\SessionAuthenticator' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/SessionAuthenticator.php',
             'Poweradmin\\Infrastructure\\Service\\SqlDnsBackendProvider' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/SqlDnsBackendProvider.php',
             'Poweradmin\\Infrastructure\\Service\\StyleManager' => __DIR__ . '/../..' . '/lib/Infrastructure/Service/StyleManager.php',
    +        'Poweradmin\\Infrastructure\\Utility\\CsvFormulaEscaper' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/CsvFormulaEscaper.php',
             'Poweradmin\\Infrastructure\\Utility\\DependencyCheck' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/DependencyCheck.php',
             'Poweradmin\\Infrastructure\\Utility\\IpAddressRetriever' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/IpAddressRetriever.php',
             'Poweradmin\\Infrastructure\\Utility\\LanguageCode' => __DIR__ . '/../..' . '/lib/Infrastructure/Utility/LanguageCode.php',
    

Vulnerability mechanics

Root cause

"User-controlled data in the username field is written to exported CSV files without sanitizing formula trigger characters."

Attack vector

An attacker with the ability to create user accounts must first create an account with a specially crafted username containing formula trigger characters (e.g., `=HYPERLINK("http://attacker.com","Click here")`). After logging out and logging back in with this new account to generate a log entry, the attacker, or a victim administrator, can then navigate to the logs section and export the activity logs as a CSV file. Opening this CSV file in a spreadsheet application will cause the embedded formula to execute [ref_id=1].

Affected code

The vulnerability exists in four log export controllers: `ListLogUsersController.php`, `ListLogZonesController.php`, `ListLogGroupsController.php`, and `ListLogApiController.php`. These controllers export database rows using `fputcsv()` without applying any formula injection countermeasures to the `user` and `username` columns [ref_id=1].

What the fix does

The patch addresses the CSV injection vulnerability by sanitizing the username field before it is written to the CSV export. Specifically, it ensures that characters that could trigger formulas in spreadsheet applications are properly escaped or removed. This prevents spreadsheet software from interpreting user-supplied data as executable formulas, thereby mitigating phishing and data exfiltration risks [patch_id=5276385].

Preconditions

  • authAn account with `user_add_new` permission (administrator role) is required to create the malicious user account.
  • inputThe username field must be controllable by the attacker and contain formula trigger characters.

Reproduction

1. Log in as an administrator. 2. Create a new user account with the username `=HYPERLINK("http://attacker.com","Confirm Identity")`. 3. Log out and log in with the newly created account to generate a log entry. 4. Log back in as an administrator. 5. Navigate to `/users/logs` and click 'Export CSV'. 6. Open the downloaded CSV file in Microsoft Excel or LibreOffice Calc. The `user` column will display a clickable hyperlink to `http://attacker.com` [ref_id=1].

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.