$allowedHosts lower-case host names, e.g. the site host */ public function __construct(private array $allowedHosts) { $this->allowedHosts = array_values(array_unique(array_map('strtolower', $allowedHosts))); } /** @return array{text:string, removed:int} */ public function sanitize(string $text): array { $removed = 0; $text = (string) preg_replace_callback('/!\[[^\]]*\]\([^)]*\)/u', function () use (&$removed): string { $removed++; return ''; }, $text); $text = (string) preg_replace_callback('/<\/?[a-zA-Z!][^>]*>/u', function () use (&$removed): string { $removed++; return ''; }, $text); $text = (string) preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]*)\)/u', function (array $m) use (&$removed): string { $target = self::decode($m[2]); if ($target !== '' && $this->allowed($target)) { return $m[1] . ' (' . $target . ')'; } $removed++; return $m[1]; }, $text); $parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; foreach ($parts as $i => $token) { if ($i % 2 === 1 || $token === '') { continue; // whitespace } if ($this->tokenHasForbiddenDestination($token)) { $parts[$i] = self::REMOVED; $removed++; } } $text = implode('', $parts); return ['text' => trim((string) preg_replace('/[ \t]{2,}/', ' ', $text)), 'removed' => $removed]; } public function tokenHasForbiddenDestination(string $token): bool { $decoded = self::decode($token); if (preg_match(self::DANGEROUS_SCHEME, $decoded) || preg_match(self::DANGEROUS_SCHEME, $token)) { return true; } foreach ([$decoded, $token] as $form) { if (preg_match_all(self::DESTINATION, $form, $m)) { foreach ($m[0] as $candidate) { $url = rtrim($candidate, '.,;:!?)]}\'"'); if (!$this->allowed($url)) { return true; } } } } return false; } private static function decode(string $s): string { $prev = ''; for ($i = 0; $i < 3 && $prev !== $s; $i++) { // nested encodings, bounded $prev = $s; $s = rawurldecode(html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8')); } return $s; } private function allowed(string $url): bool { if (str_starts_with($url, '//')) { return false; // protocol-relative: never allowed as written } if (stripos($url, 'www.') === 0) { $url = 'https://' . $url; } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- pure PHP helper without a WordPress dependency (unit-tested standalone) $parts = parse_url($url); if ($parts === false || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || !isset($parts['host'])) { return false; } if (isset($parts['user']) || isset($parts['pass'])) { return false; // https://site@evil.example style } return in_array(strtolower($parts['host']), $this->allowedHosts, true); } }