| @@ -16,8 +16,16 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | class NotificationData |
| 18 | 18 | { |
| 19 | 19 | /** |
| 20 | + * Kept in sync with the token withSiteHost() swaps in src/Notifications/notification-link.js. | |
| 21 | + * | |
| 22 | + * @var string | |
| 23 | + */ | |
| 24 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility | |
| 25 | + const SITE_URL_TOKEN = '{SITEURL}'; | |
| 26 | + | |
| 27 | + /** | |
| 20 | 28 | * Registers the wp-cron handler that refreshes stale notifications. |
| 21 | 29 | * |
| 22 | 30 | * @return void |
| 23 | 31 | */ |
| @@ -41,9 +49,9 @@ | ||
| 41 | 49 | $locale = \get_locale(); |
| 42 | 50 | $cached = \get_option('extendify_notifications_' . $locale); |
| 43 | 51 | |
| 44 | 52 | if (!is_array($cached) || !isset($cached['fetchedAt'])) { |
| 45 | - return self::refresh($locale) ?? []; | |
| 53 | + return self::withSafeLinks(self::refresh($locale) ?? []); | |
| 46 | 54 | } |
| 47 | 55 | |
| 48 | 56 | $age = time() - $cached['fetchedAt']; |
| 49 | 57 | // A host whose requests are being refused would otherwise re-ask every TTL. |
| @@ -56,9 +64,56 @@ | ||
| 56 | 64 | } |
| 57 | 65 | } |
| 58 | 66 | } |
| 59 | 67 | |
| 60 | - return $cached['data'] ?? []; | |
| 68 | + return self::withSafeLinks($cached['data'] ?? []); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** | |
| 72 | + * Drops any link a browser must not follow. | |
| 73 | + * | |
| 74 | + * A feed link becomes an href in wp-admin, so javascript: would run as the site owner. | |
| 75 | + * | |
| 76 | + * @param mixed $notifications - Notifications as the feed sent them. | |
| 77 | + * @return array | |
| 78 | + */ | |
| 79 | + private static function withSafeLinks($notifications) | |
| 80 | + { | |
| 81 | + if (!is_array($notifications)) { | |
| 82 | + return []; | |
| 83 | + } | |
| 84 | + | |
| 85 | + return array_map([self::class, 'withSafeLink'], $notifications); | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * Drops one notification's link unless it is http, https or site-relative. | |
| 90 | + * | |
| 91 | + * esc_url_raw strips the placeholder's braces, so only the probe copy goes through it. | |
| 92 | + * | |
| 93 | + * @param mixed $notification - One notification as the feed sent it. | |
| 94 | + * @return mixed | |
| 95 | + */ | |
| 96 | + private static function withSafeLink($notification) | |
| 97 | + { | |
| 98 | + if (!is_array($notification) || !isset($notification['link'])) { | |
| 99 | + return $notification; | |
| 100 | + } | |
| 101 | + | |
| 102 | + if (!is_string($notification['link'])) { | |
| 103 | + unset($notification['link']); | |
| 104 | + | |
| 105 | + return $notification; | |
| 106 | + } | |
| 107 | + | |
| 108 | + $host = (string) \wp_parse_url(\home_url(), PHP_URL_HOST); | |
| 109 | + $probe = str_replace(self::SITE_URL_TOKEN, $host, $notification['link']); | |
| 110 | + | |
| 111 | + if (\esc_url_raw($probe, ['http', 'https']) === '') { | |
| 112 | + unset($notification['link']); | |
| 113 | + } | |
| 114 | + | |
| 115 | + return $notification; | |
| 61 | 116 | } |
| 62 | 117 | |
| 63 | 118 | /** |
| 64 | 119 | * Fetch notifications from the API and persist them. |