From 75807bbf3d2466625e83d305e2352cb26a35d776 Mon Sep 17 00:00:00 2001 From: Matheus Cabral Date: Wed, 23 Sep 2026 15:18:17 -0300 Subject: [PATCH 1/2] Escape spreadsheet formulas when exporting visits as CSV User-Agent and Referer are set by whoever visits a short URL, and were written verbatim to the CSV produced by the visits commands, so values starting with =, -, +, @, tab or CR were evaluated as formulas when the file was opened in a spreadsheet (CVE-2026-18738). Enable league/csv's EscapeFormula formatter, which prefixes those values with a single quote. All visits commands render CSV through VisitsCommandUtils::renderOutput, so this covers short-url:visits, domain:visits, tag:visits, visit:orphan and visit:non-orphan. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 17 +++++++++++ .../src/Command/Visit/VisitsCommandUtils.php | 2 ++ .../ShortUrl/GetShortUrlVisitsCommandTest.php | 28 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 403bb86c3..6b30501b2 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 +* [#XXXX](https://github.com/shlinkio/shlink/issues/XXXX) 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' => [ From 1db12b1641d1d03faf4593157f8fa351660adfa7 Mon Sep 17 00:00:00 2001 From: Matheus Cabral Date: Wed, 23 Sep 2026 15:26:59 -0300 Subject: [PATCH 2/2] Link PR in changelog entry Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b30501b2..72b4fd931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* [#XXXX](https://github.com/shlinkio/shlink/issues/XXXX) 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). +* [#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