diff --git a/CHANGELOG.md b/CHANGELOG.md index 403bb86c3..72b4fd931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#2665](https://github.com/shlinkio/shlink/pull/2665) Escape values that could be interpreted as spreadsheet formulas when exporting visits in CSV format, to address [CVE-2026-18738](https://nvd.nist.gov/vuln/detail/CVE-2026-18738). + + ## [5.1.7] - 2026-09-21 ### Added * *Nothing* diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php index 81e40b381..4642fcb8b 100644 --- a/module/CLI/src/Command/Visit/VisitsCommandUtils.php +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; +use League\Csv\EscapeFormula; use League\Csv\Writer; use Shlinkio\Shlink\CLI\Input\VisitsListFormat; use Shlinkio\Shlink\CLI\Input\VisitsListInput; @@ -48,6 +49,7 @@ private static function renderCSVOutput(OutputInterface $output, Paginator $pagi [$rows, $headers] = self::resolveRowsAndHeaders($paginator); $csv = Writer::fromString(); + $csv->addFormatter(new EscapeFormula()->escapeRecord(...)); if ($page === 1) { $csv->insertOne($headers); } diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 9f4ac2c43..f9df4286b 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -119,6 +119,34 @@ public function outputIsProperlyGenerated(VisitsListFormat $format, callable $ge self::assertEquals($getExpectedOutput($visit->date), $output); } + #[Test] + public function formulasAreEscapedInCsvOutput(): void + { + $maliciousVisit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('=1+1', '@SUM(A1)')); + $regularVisit = Visit::forValidShortUrl( + ShortUrl::createFake(), + Visitor::fromParams('Mozilla/5.0', 'https://example.com'), + ); + $shortCode = 'abc123'; + $this->visitsHelper + ->expects($this->once()) + ->method('visitsForShortUrl') + ->willReturn(new Paginator(new ArrayAdapter([$maliciousVisit, $regularVisit]))); + + $this->commandTester->execute(['short-code' => $shortCode, '--format' => VisitsListFormat::CSV->value]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<date->toAtomString()},,'=1+1,'@SUM(A1),Unknown,Unknown,Unknown,,Unknown,valid_short_url + {$regularVisit->date->toAtomString()},,Mozilla/5.0,https://example.com,Unknown,Unknown,Unknown,,Unknown,valid_short_url + + OUTPUT, + $output, + ); + } + public static function provideOutput(): iterable { yield 'regular' => [