| @@ -143,39 +143,75 @@ | ||
| 143 | 143 | |
| 144 | 144 | if ($transformer && is_string($transformer) && $value) { |
| 145 | 145 | switch ($transformer) { |
| 146 | 146 | case 'trim': |
| 147 | - return trim($value); | |
| 147 | + $value = trim($value); | |
| 148 | + break; | |
| 148 | 149 | case 'ucfirst': |
| 149 | - return ucfirst($value); | |
| 150 | + $value = ucfirst($value); | |
| 151 | + break; | |
| 150 | 152 | case 'strtolower': |
| 151 | - return strtolower($value); | |
| 153 | + $value = strtolower($value); | |
| 154 | + break; | |
| 152 | 155 | case 'strtoupper': |
| 153 | - return strtoupper($value); | |
| 156 | + $value = strtoupper($value); | |
| 157 | + break; | |
| 154 | 158 | case 'ucwords': |
| 155 | - return ucwords($value); | |
| 159 | + $value = ucwords($value); | |
| 160 | + break; | |
| 156 | 161 | case 'concat_first': // usage: {{contact.first_name||concat_first|Hi |
| 157 | 162 | if (isset($valueKeys[3])) { |
| 158 | 163 | $value = trim($valueKeys[3] . ' ' . $value); |
| 159 | 164 | } |
| 160 | - return $value; | |
| 165 | + break; | |
| 161 | 166 | case 'concat_last': // usage: {{contact.first_name||concat_last|, => FIRST_NAME, |
| 162 | 167 | if (isset($valueKeys[3])) { |
| 163 | 168 | $value = trim($value . '' . $valueKeys[3]); |
| 164 | 169 | } |
| 165 | - return $value; | |
| 170 | + break; | |
| 166 | 171 | case 'show_if': // usage {{contact.first_name||show_if|First name exist |
| 167 | 172 | if (isset($valueKeys[3])) { |
| 168 | 173 | $value = $valueKeys[3]; |
| 169 | 174 | } |
| 170 | - return $value; | |
| 171 | - default: | |
| 172 | - return $value; | |
| 175 | + break; | |
| 173 | 176 | } |
| 174 | 177 | } |
| 175 | 178 | |
| 176 | - return $value; | |
| 179 | + return $this->escapeValueForContext($value, $dataKey, $valueKey); | |
| 180 | + } | |
| 177 | 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); | |
| 178 | 214 | } |
| 179 | 215 | |
| 180 | 216 | protected function getWpValue($valueKey, $defaultValue) |
| 181 | 217 | { |
| @@ -226,8 +262,22 @@ | ||
| 226 | 262 | |
| 227 | 263 | $wpUser = $userModel->getWpUser(); |
| 228 | 264 | $valueKeys = explode('.', $valueKey); |
| 229 | 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 | + | |
| 230 | 280 | $value = $wpUser->get($valueKey); |
| 231 | 281 | if (!$value) { |
| 232 | 282 | return $defaultValue; |
| 233 | 283 | } |
| @@ -251,8 +301,18 @@ | ||
| 251 | 301 | return '<img ' . $style . ' src="' . esc_url($userModel->photo) . '" alt="' . esc_attr($userModel->display_name) . '" class="fcom_user_dynamic_photo" />'; |
| 252 | 302 | } |
| 253 | 303 | |
| 254 | 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 | + | |
| 255 | 315 | $metaValue = get_user_meta($wpUser->ID, $customProperty, true); |
| 256 | 316 | if (!$metaValue) { |
| 257 | 317 | return $defaultValue; |
| 258 | 318 | } |