| @@ -51,18 +51,39 @@ | ||
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | public function parseShortcode($string) |
| 54 | 54 | { |
| 55 | - // check if the string contains any smartcode | |
| 56 | 55 | if (strpos($string, '{{') === false && strpos($string, '##') === false) { |
| 57 | 56 | return $string; |
| 58 | 57 | } |
| 59 | 58 | |
| 59 | + if (static::$isHtml) { | |
| 60 | + $string = $this->resolveHrefPlaceholders($string); | |
| 61 | + } | |
| 62 | + | |
| 60 | 63 | return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) { |
| 61 | 64 | return $this->replace($matches); |
| 62 | 65 | }, $string); |
| 63 | 66 | } |
| 64 | 67 | |
| 68 | + /** | |
| 69 | + * Resolve placeholders inside href="..." attributes to raw URLs. | |
| 70 | + * Prevents nested anchors when URL smartcodes auto-wrap in HTML mode. | |
| 71 | + */ | |
| 72 | + protected function resolveHrefPlaceholders($string) | |
| 73 | + { | |
| 74 | + return preg_replace_callback( | |
| 75 | + '/(?<![a-zA-Z-])href\s*=\s*(["\'])([^"\']*(?:\{\{|##)[^"\']*)\1/i', | |
| 76 | + function ($m) { | |
| 77 | + static::$isHtml = false; | |
| 78 | + $resolved = $this->parseShortcode($m[2]); | |
| 79 | + static::$isHtml = true; | |
| 80 | + return 'href=' . $m[1] . esc_url($resolved) . $m[1]; | |
| 81 | + }, | |
| 82 | + $string | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + | |
| 65 | 86 | protected function replace($matches) |
| 66 | 87 | { |
| 67 | 88 | if (empty($matches[2])) { |
| 68 | 89 | return apply_filters('fluent_community/smartcode_fallback', $matches[0], $this->store['user']); |
| @@ -122,39 +143,75 @@ | ||
| 122 | 143 | |
| 123 | 144 | if ($transformer && is_string($transformer) && $value) { |
| 124 | 145 | switch ($transformer) { |
| 125 | 146 | case 'trim': |
| 126 | - return trim($value); | |
| 147 | + $value = trim($value); | |
| 148 | + break; | |
| 127 | 149 | case 'ucfirst': |
| 128 | - return ucfirst($value); | |
| 150 | + $value = ucfirst($value); | |
| 151 | + break; | |
| 129 | 152 | case 'strtolower': |
| 130 | - return strtolower($value); | |
| 153 | + $value = strtolower($value); | |
| 154 | + break; | |
| 131 | 155 | case 'strtoupper': |
| 132 | - return strtoupper($value); | |
| 156 | + $value = strtoupper($value); | |
| 157 | + break; | |
| 133 | 158 | case 'ucwords': |
| 134 | - return ucwords($value); | |
| 159 | + $value = ucwords($value); | |
| 160 | + break; | |
| 135 | 161 | case 'concat_first': // usage: {{contact.first_name||concat_first|Hi |
| 136 | 162 | if (isset($valueKeys[3])) { |
| 137 | 163 | $value = trim($valueKeys[3] . ' ' . $value); |
| 138 | 164 | } |
| 139 | - return $value; | |
| 165 | + break; | |
| 140 | 166 | case 'concat_last': // usage: {{contact.first_name||concat_last|, => FIRST_NAME, |
| 141 | 167 | if (isset($valueKeys[3])) { |
| 142 | 168 | $value = trim($value . '' . $valueKeys[3]); |
| 143 | 169 | } |
| 144 | - return $value; | |
| 170 | + break; | |
| 145 | 171 | case 'show_if': // usage {{contact.first_name||show_if|First name exist |
| 146 | 172 | if (isset($valueKeys[3])) { |
| 147 | 173 | $value = $valueKeys[3]; |
| 148 | 174 | } |
| 149 | - return $value; | |
| 150 | - default: | |
| 151 | - return $value; | |
| 175 | + break; | |
| 152 | 176 | } |
| 153 | 177 | } |
| 154 | 178 | |
| 155 | - return $value; | |
| 179 | + return $this->escapeValueForContext($value, $dataKey, $valueKey); | |
| 180 | + } | |
| 156 | 181 | |
| 182 | + /** | |
| 183 | + * Smartcode values are substituted into lockscreen/lesson/email HTML *after* | |
| 184 | + * that content has passed through wp_kses / do_blocks, so a resolved scalar | |
| 185 | + * carrying markup would otherwise bypass sanitisation. Escape ordinary | |
| 186 | + * values for the HTML context. The few branches that intentionally build a | |
| 187 | + * trusted fragment (photo_html, name_with_url, section url) already escape | |
| 188 | + * their own interpolated parts, so they are left untouched, and values from | |
| 189 | + * third-party group callbacks are the extension's responsibility. | |
| 190 | + */ | |
| 191 | + protected function escapeValueForContext($value, $dataKey, $valueKey) | |
| 192 | + { | |
| 193 | + if (!static::$isHtml || !is_string($value) || $value === '') { | |
| 194 | + return $value; | |
| 195 | + } | |
| 196 | + | |
| 197 | + $knownGroups = ['site', 'user', 'community', 'section', 'course']; | |
| 198 | + if (!in_array($dataKey, $knownGroups, true)) { | |
| 199 | + return $value; | |
| 200 | + } | |
| 201 | + | |
| 202 | + $trustedHtml = [ | |
| 203 | + 'user' => ['photo_html'], | |
| 204 | + 'community' => ['name_with_url'], | |
| 205 | + 'section' => ['url'], | |
| 206 | + ]; | |
| 207 | + | |
| 208 | + $baseKey = strtok($valueKey, '.'); // "photo_html.50px" -> "photo_html" | |
| 209 | + if (in_array($baseKey, Arr::get($trustedHtml, $dataKey, []), true)) { | |
| 210 | + return $value; | |
| 211 | + } | |
| 212 | + | |
| 213 | + return esc_html($value); | |
| 157 | 214 | } |
| 158 | 215 | |
| 159 | 216 | protected function getWpValue($valueKey, $defaultValue) |
| 160 | 217 | { |
| @@ -205,8 +262,22 @@ | ||
| 205 | 262 | |
| 206 | 263 | $wpUser = $userModel->getWpUser(); |
| 207 | 264 | $valueKeys = explode('.', $valueKey); |
| 208 | 265 | if (count($valueKeys) == 1) { |
| 266 | + // Smartcodes are resolved against the *viewer* and can be authored by | |
| 267 | + // space/course/page admins, so only a fixed set of non-sensitive | |
| 268 | + // profile fields may be read. Never expose user_pass, | |
| 269 | + // user_activation_key, session tokens or capability meta. | |
| 270 | + $allowedFields = apply_filters('fluent_community/smartcode/user_fields', [ | |
| 271 | + 'ID', 'first_name', 'last_name', 'nickname', 'display_name', | |
| 272 | + 'user_email', 'user_login', 'user_nicename', 'user_url', | |
| 273 | + 'description', 'user_registered' | |
| 274 | + ]); | |
| 275 | + | |
| 276 | + if (!in_array($valueKey, $allowedFields, true)) { | |
| 277 | + return $defaultValue; | |
| 278 | + } | |
| 279 | + | |
| 209 | 280 | $value = $wpUser->get($valueKey); |
| 210 | 281 | if (!$value) { |
| 211 | 282 | return $defaultValue; |
| 212 | 283 | } |
| @@ -230,8 +301,18 @@ | ||
| 230 | 301 | return '<img ' . $style . ' src="' . esc_url($userModel->photo) . '" alt="' . esc_attr($userModel->display_name) . '" class="fcom_user_dynamic_photo" />'; |
| 231 | 302 | } |
| 232 | 303 | |
| 233 | 304 | if ($customKey == 'meta') { |
| 305 | + // No first-party template reads user meta through smartcodes, and the | |
| 306 | + // viewer's own meta (session_tokens, capability keys, reset keys, | |
| 307 | + // any _-prefixed value) must never leak into author-controlled | |
| 308 | + // content. Resolve only meta keys a site has explicitly allowed. | |
| 309 | + $allowedMetaKeys = apply_filters('fluent_community/smartcode/user_meta_keys', []); | |
| 310 | + | |
| 311 | + if (strpos($customProperty, '_') === 0 || !in_array($customProperty, $allowedMetaKeys, true)) { | |
| 312 | + return $defaultValue; | |
| 313 | + } | |
| 314 | + | |
| 234 | 315 | $metaValue = get_user_meta($wpUser->ID, $customProperty, true); |
| 235 | 316 | if (!$metaValue) { |
| 236 | 317 | return $defaultValue; |
| 237 | 318 | } |