[TikiWiki-commits] [Git][tikiwiki/tiki][master] [NEW] Introduce a centralized SSRF protection library in Tiki
"Elifeleti Mukisa Dan \(@Danelif\) via TikiWiki-cvs" <[email protected]>
| Newsgroups | gmane.comp.cms.tiki.cvs |
|---|---|
| Message-ID | <6a10a54c8e2a4_381966303124@gitlab-sidekiq-low-urgency-cpu-bound-v2-5755d7f9f9-9h5wm.mail> |
Elifeleti Mukisa Dan pushed to branch master at Tiki Wiki CMS Groupware / Tiki Commits: e06674bc by Elifeleti Mukisa Dan at 2026-05-22T18:32:02+00:00 [NEW] Introduce a centralized SSRF protection library in Tiki --- * [NEW] Introduce a centralized SSRF protection library in Tiki (cherry picked from commit bdf50591016dcda26c10ad9ccce83bf22a38f2eb) 322f139b [FIX] Implement a URL blocklist that rejects private IP range 6f5fd32b [NEW] Add centralized SSRF protection for server-side URL fetches 755d5f1c [FIX] Block SSRF attacks in directory URL validation Co-authored-by: Elifeleti Mukisa Dan <[email protected]> See merge request tikiwiki/tiki!10291 - - - - - 13 changed files: - lib/Importer/WikiMediawiki.php - lib/OpenIdConnect/OpenIdConnectLib.php - lib/avatarlib.php - + lib/core/Tiki/Security/SsrfLib.php - + lib/prefs/ssrf.php - lib/videogals/peertubelib.php - lib/wiki-plugins/wikiplugin_fancylink.php - lib/wiki-plugins/wikiplugin_includeurl.php - lib/wiki-plugins/wikiplugin_oembed.php - lib/wiki-plugins/wikiplugin_sheet.php - templates/admin/include_security.tpl - tiki-directory_add_site.php - tiki-directory_add_tiki_site.php Changes: ===================================== lib/Importer/WikiMediawiki.php ===================================== @@ -344,6 +344,18 @@ 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; + } + } + if (@fopen($fileUrl, 'r')) { $attachmentContent = @file_get_contents($fileUrl); $newFile = fopen($this->attachmentsDestDir . $fileName, 'w'); ===================================== lib/OpenIdConnect/OpenIdConnectLib.php ===================================== @@ -154,6 +154,12 @@ class OpenIdConnectLib $jwkArr = unserialize($cachedValue); return $jwkArr; } else { + // Validate JWKS URL to prevent SSRF + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($this->jwksUrl)) { + throw new \Exception('JWKS URL is not allowed'); + } + $jwkArr = file_get_contents($this->jwksUrl); if ($jwkArr === false) { ===================================== lib/avatarlib.php ===================================== @@ -47,6 +47,12 @@ class AvatarLib extends TikiLib $userwatch = $user; } + // Validate URL to prevent SSRF via user-supplied avatar URLs + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + throw new \Exception('Avatar URL is not allowed'); + } + $data = file_get_contents($url); list($iwidth, $iheight, $itype, $iattr) = getimagesize($url); $itype = image_type_to_mime_type($itype); ===================================== lib/core/Tiki/Security/SsrfLib.php ===================================== @@ -0,0 +1,153 @@ +<?php + +// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +namespace Tiki\Security; + +/** + * SSRF protection utilities. + * + * Validates hosts and URLs before server-side fetches to prevent + * Server-Side Request Forgery via private/reserved IP ranges, + * DNS rebinding, and redirect chains. + * + * Supports a configurable whitelist so that specific internal hosts + * can be explicitly permitted when needed. + */ +class SsrfLib +{ + /** @var string[] Hosts that are always allowed regardless of IP checks */ + private array $whitelistedHosts = []; + + /** + * @param string[] $whitelistedHosts Hostnames that bypass the private IP check + */ + public function __construct(array $whitelistedHosts = []) + { + $this->whitelistedHosts = array_map('strtolower', $whitelistedHosts); + } + + /** + * Return an instance pre-configured with the Tiki admin whitelist preference. + * + * The preference `ssrf_whitelisted_hosts` is expected to be a + * comma-separated string of hostnames stored in the Tiki preferences + * (Admin → Security). When the preference does not exist the + * whitelist is empty. + */ + public static function fromPrefs(): self + { + global $prefs; + $raw = ! empty($prefs['ssrf_whitelisted_hosts']) ? $prefs['ssrf_whitelisted_hosts'] : ''; + $hosts = array_filter(array_map('trim', explode(',', $raw))); + return new self($hosts); + } + + /** + * Check whether a host is allowed to be fetched by the server. + * + * Rejects hosts that resolve to private, loopback, link-local or + * other reserved IP ranges. If a hostname resolves to multiple + * addresses, **any** private/reserved address causes rejection. + * + * Whitelisted hosts bypass the check entirely. + * + * @param string $host Hostname or IP literal + * @return bool True if allowed, false if disallowed + */ + public function isHostAllowed(string $host): bool + { + if ($host === '') { + return false; + } + + // Whitelist check (case-insensitive) + if (in_array(strtolower($host), $this->whitelistedHosts, true)) { + return true; + } + + // IP literal — validate directly + if (filter_var($host, FILTER_VALIDATE_IP)) { + return filter_var( + $host, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + } + + $ips = $this->resolveHost($host); + + // If DNS returned nothing, allow (let the fetch itself fail) + if (empty($ips)) { + return true; + } + + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; + } + } + + return true; + } + + /** + * Validate a full URL: scheme must be http(s), and the resolved + * host must pass {@see isHostAllowed()}. + * + * @param string $url + * @return bool + */ + public function isUrlAllowed(string $url): bool + { + $url = trim($url); + + if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + + $parsed = parse_url($url); + if (! $parsed || empty($parsed['scheme']) || empty($parsed['host'])) { + return false; + } + + if (! in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) { + return false; + } + + return $this->isHostAllowed($parsed['host']); + } + + /** + * Resolve a hostname to all its A and AAAA records. + * + * @param string $host + * @return string[] IP addresses + */ + private function resolveHost(string $host): array + { + $ips = []; + + // IPv4 A records + $a = @gethostbynamel($host); + if (is_array($a)) { + $ips = $a; + } + + // IPv6 AAAA records + if (function_exists('dns_get_record')) { + $aaaa = @dns_get_record($host, DNS_AAAA); + if (is_array($aaaa)) { + foreach ($aaaa as $rec) { + if (! empty($rec['ipv6'])) { + $ips[] = $rec['ipv6']; + } + } + } + } + + return $ips; + } +} ===================================== lib/prefs/ssrf.php ===================================== @@ -0,0 +1,20 @@ +<?php + +// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +function prefs_ssrf_list() +{ + return [ + 'ssrf_whitelisted_hosts' => [ + 'name' => tra('SSRF whitelisted hosts'), + 'description' => tra('Comma-separated list of hostnames that are allowed to be fetched server-side even if they resolve to private or reserved IP addresses. Leave empty to disallow all private ranges.'), + 'type' => 'textarea', + 'size' => 3, + 'filter' => 'text', + 'default' => '', + 'tags' => ['advanced', 'security'], + ], + ]; +} ===================================== lib/videogals/peertubelib.php ===================================== @@ -69,6 +69,14 @@ class PeerTubeLib private function makeRequest($method, $url, $data = [], $auth = true, $rawBody = false) { + // Prevent SSRF: validate the request URL before cURL opens the connection. + // PeerTube base URL is admin-configured, but CURLOPT_FOLLOWLOCATION could + // follow redirects to internal hosts if the remote instance is compromised. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + throw new \Exception('PeerTube request blocked: URL targets a private or reserved address.'); + } + $ch = curl_init(); $method = strtoupper($method); curl_setopt($ch, CURLOPT_URL, $url); ===================================== lib/wiki-plugins/wikiplugin_fancylink.php ===================================== @@ -513,6 +513,11 @@ function extractUrlMetadata($url, $cacheTime = 86400) return false; } + // Prevent SSRF by validating the URL and resolved hosts. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + return false; + } // Check cache first $cacheLib = TikiLib::lib('cache'); // Use the full URL in the cache key to avoid collisions between different domains @@ -722,6 +727,8 @@ function resolveUrl($url, $baseUrl) return $scheme . $host . $port . $path . $url; } + + /** * Truncate text to a specified length * ===================================== lib/wiki-plugins/wikiplugin_includeurl.php ===================================== @@ -36,6 +36,14 @@ function wikiplugin_includeurl($data, $params) return tr('Missing parameter url for plugin %0', 'includeurl') . '<br>'; } else { $url = $params['url']; + + // Prevent SSRF: the response body is rendered into the wiki page, + // so block requests to private/reserved IPs and non-http(s) schemes. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + return tra('URL is not allowed (targets a private or reserved address).'); + } + $html = file_get_contents($url); // Only include the body part of the html file ===================================== lib/wiki-plugins/wikiplugin_oembed.php ===================================== @@ -128,6 +128,14 @@ function getOEmbedData($url) if (! filter_var($url, FILTER_VALIDATE_URL)) { throw new Exception(tr('The provided URL is not a valid URL.')); } + + // Prevent SSRF: the user-supplied URL determines which host the server contacts + // for oEmbed discovery. Block private/reserved IPs and non-http(s) schemes. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + throw new Exception(tra('URL is not allowed (targets a private or reserved address).')); + } + $parsedUrl = parse_url($url); $protocol = $parsedUrl['scheme']; $domain = $parsedUrl['host']; ===================================== lib/wiki-plugins/wikiplugin_sheet.php ===================================== @@ -241,6 +241,11 @@ EOF; $ret = $grid->getTableHtml(true, null, false); } else { $sheet->parseValues = true; + // Validate URL to prevent SSRF + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($url)) { + return tra('Sheet URL is not allowed'); + } $ret = file_get_contents($url); } } else { ===================================== templates/admin/include_security.tpl ===================================== @@ -82,6 +82,10 @@ {preference name=http_use_curl} {preference name=feature_debug_console} </fieldset> + <fieldset> + <legend class="h3">{tr}SSRF Protection{/tr}</legend> + {preference name=ssrf_whitelisted_hosts} + </fieldset> <fieldset> <legend class="h3">{tr}Trackers Security{/tr}</legend> {preference name=tracker_adminonlyviewedititem_by_default} ===================================== tiki-directory_add_site.php ===================================== @@ -103,9 +103,16 @@ if (isset($_REQUEST["save"])) { $msg .= tra("URL already added to the directory. Duplicate site? "); } if ($prefs['directory_validate_urls'] == 'y') { - @$fsh = fopen($_REQUEST['url'], 'r'); - if (! $fsh) { - $msg .= tra("URL cannot be accessed wrong URL or site is offline and cannot be added to the directory. "); + // Prevent SSRF: validate the submitted URL before the server opens a + // connection to check if the site is reachable. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($_REQUEST['url'])) { + $msg .= tra("URL is not allowed (targets a private or reserved address). "); + } else { + @$fsh = fopen($_REQUEST['url'], 'r'); + if (! $fsh) { + $msg .= tra("URL cannot be accessed wrong URL or site is offline and cannot be added to the directory. "); + } } } } ===================================== tiki-directory_add_tiki_site.php ===================================== @@ -52,6 +52,12 @@ if ($dirlib->dir_url_exists($_REQUEST['url'])) { Feedback::errorAndDie(tra("URL already added to the directory. Duplicate site?"), \Laminas\Http\Response::STATUS_CODE_409); } if ($prefs['directory_validate_urls'] == 'y') { + // Prevent SSRF: validate the submitted URL before the server opens a + // connection to check if the site is reachable. + $ssrf = \Tiki\Security\SsrfLib::fromPrefs(); + if (! $ssrf->isUrlAllowed($_REQUEST['url'])) { + Feedback::errorAndDie(tra("URL is not allowed (targets a private or reserved address)"), \Laminas\Http\Response::STATUS_CODE_400); + } @$fsh = fopen($_REQUEST['url'], 'r'); if (! $fsh) { Feedback::errorAndDie(tra("URL cannot be accessed: wrong URL or site is offline and cannot be added to the directory"), \Laminas\Http\Response::STATUS_CODE_400); View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/e06674bc84d2a10faa1a65b5c638a89f55e5ed88 -- View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/e06674bc84d2a10faa1a65b5c638a89f55e5ed88 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