[TikiWiki-commits] [Git][tikiwiki/tiki][30.x] [FIX] MediaWiki importer: block non-http(s) attachment URLs and sanitize filenames

"Alfred Syatsukwa \(@alfredsyatsukwa\) via TikiWiki-cvs" <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <6a844d66727ec_385e318681022d1@gitlab-sidekiq-low-urgency-cpu-bound-v2-b96b6f55-86stv.mail>

Alfred Syatsukwa pushed to branch 30.x at Tiki Wiki CMS Groupware / Tiki


Commits:
f32b2f7b by Alfred Syatsukwa at 2026-08-18T12:11:16+00:00
[FIX] MediaWiki importer: block non-http(s) attachment URLs and sanitize filenames
---
* [FIX] MediaWiki importer: block non-http(s) attachment URLs and sanitize filenames
---
* [FIX] MediaWiki importer: block non-http(s) attachment URLs and sanitize filenames

(cherry picked from commit 775f3e8053827faa05864c79943887d1c69ae129)

See merge request tikiwiki/tiki!10947

(cherry picked from commit dffdb584f7f51d1181ad567f37840abbd62b82be)

See merge request tikiwiki/tiki!10949

- - - - -


4 changed files:

- lib/Importer/WikiMediawiki.php
- lib/test/Importer/WikiMediawikiTest.php
- lib/test/Importer/fixtures/mediawiki_invalid_upload.xml
- lib/test/Importer/fixtures/mediawiki_sample.xml


Changes:

=====================================
lib/Importer/WikiMediawiki.php
=====================================
@@ -329,9 +329,14 @@ class WikiMediawiki extends Wiki
                 $i = $attachments->length - 1;
                 $lastVersion = $attachments->item($i);
 
-                $fileName = $lastVersion->getElementsByTagName('filename')->item(0)->nodeValue;
+                $fileName = basename($lastVersion->getElementsByTagName('filename')->item(0)->nodeValue);
                 $fileUrl = $lastVersion->getElementsByTagName('src')->item(0)->nodeValue;
 
+                if ($fileName === '' || $fileName === '.' || $fileName === '..') {
+                    $this->saveAndDisplayLog(tr('File not imported: invalid attachment filename.') . "\n", true);
+                    continue;
+                }
+
                 if (file_exists($this->attachmentsDestDir . $fileName)) {
                     $this->saveAndDisplayLog(
                         tr(
@@ -344,20 +349,21 @@ class WikiMediawiki extends Wiki
                     continue;
                 }
 
-                // Prevent SSRF: attachment URLs come from the imported XML dump
-                // which may be untrusted. Block private/reserved IP targets.
-                // Only check URLs with a scheme (http/https); relative file paths
-                // are local references and not a network SSRF vector.
-                if (preg_match('#^https?://#i', $fileUrl)) {
-                    $ssrf = \Tiki\Security\SsrfLib::fromPrefs();
-                    if (! $ssrf->isUrlAllowed($fileUrl)) {
-                        $this->saveAndDisplayLog(tr('File %0 not imported: URL targets a private or reserved address.', $fileName) . "\n", true);
-                        continue;
-                    }
+                // Prevent SSRF / local-file disclosure: attachment <src> values come
+                // from the imported XML dump and may be untrusted. Always validate
+                // with SsrfLib (http/https allowlist + reject private/reserved hosts)
+                // so file://, php://, and other stream wrappers cannot bypass the gate.
+                $ssrf = \Tiki\Security\SsrfLib::fromPrefs();
+                if (! $ssrf->isUrlAllowed($fileUrl)) {
+                    $this->saveAndDisplayLog(
+                        tr('File %0 not imported: attachment URL must be a public http(s) address.', $fileName) . "\n",
+                        true
+                    );
+                    continue;
                 }
 
-                if (@fopen($fileUrl, 'r')) {
-                    $attachmentContent = @file_get_contents($fileUrl);
+                $attachmentContent = $this->fetchAttachmentContents($fileUrl);
+                if ($attachmentContent !== false) {
                     $newFile = fopen($this->attachmentsDestDir . $fileName, 'w');
                     fwrite($newFile, $attachmentContent);
                     $this->saveAndDisplayLog(tr('File %0 successfully imported!', $fileName) . "\n");
@@ -368,6 +374,18 @@ class WikiMediawiki extends Wiki
         }
     }
 
+    /**
+     * Fetch attachment bytes from a validated URL.
+     * Isolated for unit tests so downloads can be mocked without network I/O.
+     *
+     * @param string $fileUrl
+     * @return string|false
+     */
+    protected function fetchAttachmentContents($fileUrl)
+    {
+        return @file_get_contents($fileUrl);
+    }
+
     /**
      * Parse an DOM representation of a Mediawiki page and return all the values
      * that will be imported (page name, page content for all revisions). The


=====================================
lib/test/Importer/WikiMediawikiTest.php
=====================================
@@ -200,32 +200,22 @@ class WikiMediawikiTest extends AbstractImporterTestCase
     {
         ob_start();
 
-        $this->obj->attachmentsDestDir = __DIR__ . '/fixtures/';
-
-        $sourceAttachments = ['sourceTest.jpg', 'sourceTest2.jpg'];
-        $destAttachments = ['test.jpg', 'test2.jpg'];
-        $i = count($sourceAttachments) - 1;
-        $cwd = getcwd();
-        chdir(__DIR__);
-
-        while ($i >= 0) {
-            fopen($this->obj->attachmentsDestDir . $sourceAttachments[$i], 'w');
-            $i--;
-        }
+        $obj = $this->getMockBuilder(WikiMediawiki::class)
+            ->onlyMethods(['fetchAttachmentContents'])
+            ->getMock();
+        $obj->attachmentsDestDir = __DIR__ . '/fixtures/';
+        $obj->method('fetchAttachmentContents')->willReturn('fake-image-bytes');
 
-        $this->obj->dom = new DOMDocument();
-        $this->obj->dom->load(__DIR__ . '/fixtures/mediawiki_sample.xml');
-        $this->obj->downloadAttachments();
+        $obj->dom = new DOMDocument();
+        $obj->dom->load(__DIR__ . '/fixtures/mediawiki_sample.xml');
+        $obj->downloadAttachments();
 
-        $i = count($sourceAttachments) - 1;
-        while ($i >= 0) {
-            $filePath = $this->obj->attachmentsDestDir . $destAttachments[$i];
+        foreach (['test.jpg', 'test2.jpg'] as $attachment) {
+            $filePath = $obj->attachmentsDestDir . $attachment;
             $this->assertFileExists($filePath);
+            $this->assertSame('fake-image-bytes', file_get_contents($filePath));
             unlink($filePath);
-            unlink($this->obj->attachmentsDestDir . $sourceAttachments[$i]);
-            $i--;
         }
-        chdir($cwd);
 
         $output = ob_get_clean();
         $this->assertEquals("\n\nImporting attachments:\nFile test2.jpg successfully imported!\nFile test.jpg successfully imported!\n", $output);
@@ -271,13 +261,57 @@ class WikiMediawikiTest extends AbstractImporterTestCase
     {
         ob_start();
 
+        $obj = $this->getMockBuilder(WikiMediawiki::class)
+            ->onlyMethods(['fetchAttachmentContents'])
+            ->getMock();
+        $obj->attachmentsDestDir = __DIR__ . '/fixtures/';
+        $obj->method('fetchAttachmentContents')->willReturn(false);
+        $obj->dom = new DOMDocument();
+        $obj->dom->load(__DIR__ . '/fixtures/mediawiki_invalid_upload.xml');
+        $obj->downloadAttachments();
+
+        $output = ob_get_clean();
+        $this->assertEquals("\n\nImporting attachments:\nUnable to download file Qlandkartegt-0.11.1.tar.gz. File not found.\nUnable to download file Passelivre.jpg. File not found.\n", $output);
+    }
+
+    public function testDownloadAttachmentsShouldRejectNonHttpSrc(): void
+    {
+        ob_start();
+
+        $xml = <<<'XML'
+<mediawiki>
+  <page>
+    <upload>
+      <filename>evil.png</filename>
+      <src>file:///etc/passwd</src>
+      <size>1</size>
+    </upload>
+  </page>
+  <page>
+    <upload>
+      <filename>local.php.png</filename>
+      <src>db/local.php</src>
+      <size>1</size>
+    </upload>
+  </page>
+</mediawiki>
+XML;
+
         $this->obj->attachmentsDestDir = __DIR__ . '/fixtures/';
         $this->obj->dom = new DOMDocument();
-        $this->obj->dom->load(__DIR__ . '/fixtures/mediawiki_invalid_upload.xml');
+        $this->obj->dom->loadXML($xml);
         $this->obj->downloadAttachments();
 
+        $this->assertFileDoesNotExist($this->obj->attachmentsDestDir . 'evil.png');
+        $this->assertFileDoesNotExist($this->obj->attachmentsDestDir . 'local.php.png');
+
         $output = ob_get_clean();
-        $this->assertEquals("\n\nImporting attachments:\nUnable to download file Qlandkartegt-0.11.1.tar.gz. File not found.\nUnable to download file Passelivre.jpg. File not found.\n", $output);
+        $this->assertEquals(
+            "\n\nImporting attachments:\n"
+            . "File evil.png not imported: attachment URL must be a public http(s) address.\n"
+            . "File local.php.png not imported: attachment URL must be a public http(s) address.\n",
+            $output
+        );
     }
 
     public function testExtractInfo(): void


=====================================
lib/test/Importer/fixtures/mediawiki_invalid_upload.xml
=====================================
@@ -8,7 +8,7 @@
       </contributor>
       <comment>adsfasdf</comment>
       <filename>Qlandkartegt-0.11.1.tar.gz</filename>
-      <src>fixtures/test3.jpg</src>
+      <src>https://example.com/missing-qlandkartegt.tar.gz</src>
       <size>3252598</size>
     </upload>
     <upload>
@@ -19,7 +19,7 @@
       </contributor>
       <comment>adsfasdf</comment>
       <filename>Qlandkartegt-0.11.1.tar.gz</filename>
-      <src>fixtures/test3.jpg</src>
+      <src>https://example.com/missing-qlandkartegt.tar.gz</src>
       <size>3252598</size>
     </upload>
   </page>
@@ -32,7 +32,7 @@
       </contributor>
       <comment />
       <filename>Passelivre.jpg</filename>
-      <src>fixtures/test3.jpg</src>
+      <src>https://example.com/missing-passelivre.jpg</src>
       <size>94751</size>
     </upload>
   </page>


=====================================
lib/test/Importer/fixtures/mediawiki_sample.xml
=====================================
@@ -190,7 +190,7 @@
       </contributor>
       <comment>adsfasdf</comment>
       <filename>test3.jpg</filename>
-      <src>fixtures/sourceTest3.jpg</src>
+      <src>https://example.com/sourceTest3.jpg</src>
       <size>3252598</size>
     </upload>
     <upload>
@@ -201,7 +201,7 @@
       </contributor>
       <comment>adsfasdf</comment>
       <filename>test2.jpg</filename>
-      <src>fixtures/sourceTest2.jpg</src>
+      <src>https://example.com/sourceTest2.jpg</src>
       <size>3252598</size>
     </upload>
   </page>
@@ -234,7 +234,7 @@
       </contributor>
       <comment />
       <filename>test.jpg</filename>
-      <src>fixtures/sourceTest.jpg</src>
+      <src>https://example.com/sourceTest.jpg</src>
       <size>94751</size>
     </upload>
   </page>



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/f32b2f7b61530b5a26847ecd96f4167fbc7fafb3

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/f32b2f7b61530b5a26847ecd96f4167fbc7fafb3
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.