Doubles
3 weeks ago
Providers
3 weeks ago
BulkRearmOnGrowthTest.php
3 weeks ago
BulkReviewsUpdateStuckStateTest.php
3 weeks ago
ClearCacheRelayResetTest.php
3 weeks ago
DeleteSourceRelayFailureTest.php
3 weeks ago
ErrorHandlerFalsyOptionTest.php
3 weeks ago
FeedCacheUpdateServiceTest.php
3 weeks ago
FeedMalformedPayloadTest.php
3 weeks ago
ForceKeylessRefetchTest.php
3 weeks ago
LicenseDeactivateStaleStateTest.php
3 weeks ago
MediaFinderMemoTest.php
3 weeks ago
MultiSourceAggregationTest.php
3 weeks ago
ReconcileMigratedLicenseRoutineTest.php
3 weeks ago
ReconcileRemovalTest.php
3 weeks ago
RegisterWebsiteRoutineTest.php
3 weeks ago
RemoteRequestMemoTest.php
3 weeks ago
ReviewAlertHeaderTotalsTest.php
3 weeks ago
ReviewAlertPageTargetingTest.php
3 weeks ago
ReviewAlertStarFillTest.php
3 weeks ago
ShortcodeNeutralizationTest.php
3 weeks ago
SiteMigrationRecoveryTest.php
3 weeks ago
Smash1583HeaderParityTest.php
3 weeks ago
Smash1631MultiLanguageBulkTest.php
3 weeks ago
Smash1631UpdateSingleLangScopeTest.php
3 weeks ago
Smash1706TripAdvisorPlaceIdTest.php
3 weeks ago
Smash1756SchemaServiceTest.php
3 weeks ago
Smash1785AvatarLocalUrlGuardTest.php
3 weeks ago
Smash1785AvatarReHealTest.php
3 weeks ago
Smash1795ReviewTextXssTest.php
3 weeks ago
Smash782BookingHeaderRatingTest.php
3 weeks ago
Smash782CountryFlagEmojiTest.php
3 weeks ago
Smash782ExternalRefreshCronTest.php
3 weeks ago
Smash782ExtrasTemplateTest.php
3 weeks ago
Smash782ReviewAlertProviderDataTest.php
3 weeks ago
SourceIdLookupTest.php
3 weeks ago
WpmlGetCurrentLanguageTest.php
3 weeks ago
WpmlLanguageMappingTest.php
3 weeks ago
Smash1795ReviewTextXssTest.php
698 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SmashBalloon\Reviews\Tests\Unit; |
| 4 | |
| 5 | use PHPUnit\Framework\TestCase; |
| 6 | use SmashBalloon\Reviews\Common\Parser; |
| 7 | use SmashBalloon\Reviews\Common\Util; |
| 8 | |
| 9 | /** |
| 10 | * SMASH-1795 — stored XSS through a review body's emoji alt attribute. |
| 11 | * |
| 12 | * The chain that was confirmed executing on a live page before this fix: |
| 13 | * |
| 14 | * 1. a review body reaches storage carrying `<img class="emoji" alt="…">` |
| 15 | * 2. Parser::get_text() html_entity_decode()s on the way out, re-arming any |
| 16 | * entity-encoded markup a sanitiser had neutralised |
| 17 | * 3. wp_kses_post() at the template keeps img + class + src + alt |
| 18 | * 4. the feed script's stripEmojihtml() does |
| 19 | * `.replaceWith($(this).attr('alt'))`, and jQuery parses that string as HTML |
| 20 | * |
| 21 | * Every link is closed independently, so no single regression re-opens it. The JS |
| 22 | * half (step 4) lives in assets/js/sbr-feed.js and its .min twin and is covered by |
| 23 | * the live PoC recorded on the ticket rather than here. |
| 24 | */ |
| 25 | class Smash1795ReviewTextXssTest extends TestCase |
| 26 | { |
| 27 | protected function setUp(): void |
| 28 | { |
| 29 | parent::setUp(); |
| 30 | // sbr_kses_review_text() is a global helper in sbr-functions.php. The |
| 31 | // bootstrap already provides the stubs that make this file requirable — |
| 32 | // same pattern as SiteMigrationRecoveryTest. |
| 33 | require_once dirname(__DIR__, 2) . '/class/sbr-functions.php'; |
| 34 | } |
| 35 | |
| 36 | /** The alt payload the audit used, and the shape it takes once stored. */ |
| 37 | private const EMOJI_PAYLOAD = '<img class="emoji" alt="<img src=x onerror=alert(1)>"> nice place'; |
| 38 | |
| 39 | // ---------- step 3: the render allowlist ---------- |
| 40 | |
| 41 | public function test_review_text_allowlist_strips_img(): void |
| 42 | { |
| 43 | $out = sbr_kses_review_text(self::EMOJI_PAYLOAD); |
| 44 | |
| 45 | $this->assertStringNotContainsString('<img', $out, 'an img must never survive into a rendered review body'); |
| 46 | $this->assertStringNotContainsString('emoji', $out); |
| 47 | $this->assertStringContainsString('nice place', $out, 'the prose around it must survive'); |
| 48 | } |
| 49 | |
| 50 | public function test_review_text_allowlist_keeps_the_emoji_when_it_drops_the_img(): void |
| 51 | { |
| 52 | // Dropping <img> must not silently delete a staticized emoji. The allowlist |
| 53 | // resolves the alt to text first — the server-side twin of stripEmojihtml() — |
| 54 | // so the glyph shows instead of vanishing. |
| 55 | $out = sbr_kses_review_text('Great stay <img class="emoji" alt="😀" src="s.png"> thanks'); |
| 56 | $this->assertStringNotContainsString('<img', $out); |
| 57 | $this->assertStringContainsString('😀', $out, 'the emoji must survive as its glyph'); |
| 58 | $this->assertStringContainsString('Great stay', $out); |
| 59 | |
| 60 | // A single-quoted alt and an extra class must resolve too. |
| 61 | $this->assertStringContainsString( |
| 62 | '🎉', |
| 63 | sbr_kses_review_text("<img src='s.png' class='wp-smiley emoji' alt='🎉'>") |
| 64 | ); |
| 65 | |
| 66 | // An emoji img with no alt leaves nothing behind — not the literal string |
| 67 | // "undefined", which is the JS-side trap the `|| ''` guard exists for. |
| 68 | $none = sbr_kses_review_text('a <img class="emoji" src="s.png"> b'); |
| 69 | $this->assertStringNotContainsString('undefined', $none); |
| 70 | $this->assertStringNotContainsString('<img', $none); |
| 71 | |
| 72 | // And the alt lands as TEXT, never markup — this is the payload the ticket is |
| 73 | // about. The word "onerror" is still PRESENT in the output and that is correct: |
| 74 | // it is inside an escaped string, so the assertion has to be that nothing is a |
| 75 | // live tag, not that the substring is absent. |
| 76 | $evil = sbr_kses_review_text('<img class="emoji" alt="<img src=x onerror=alert(1)>"> hi'); |
| 77 | $this->assertStringNotContainsString('<', $evil, 'the resolved alt must contain no live markup at all'); |
| 78 | $this->assertStringContainsString('<img', $evil, 'it must be present, escaped'); |
| 79 | $this->assertStringContainsString('hi', $evil); |
| 80 | } |
| 81 | |
| 82 | public function test_review_text_allowlist_strips_script_and_handlers(): void |
| 83 | { |
| 84 | $this->assertStringNotContainsString('<script', sbr_kses_review_text('<script>alert(1)</script>hi')); |
| 85 | $this->assertStringNotContainsString('<iframe', sbr_kses_review_text('<iframe src="//evil"></iframe>hi')); |
| 86 | $this->assertStringNotContainsString('<svg', sbr_kses_review_text('<svg onload=alert(1)></svg>hi')); |
| 87 | // Links ARE allowed — WooCommerce/EDD bodies legitimately contain them — but |
| 88 | // only with a safe protocol. wp_kses() drops the href rather than the tag. |
| 89 | $js = sbr_kses_review_text('<a href="javascript:alert(1)">x</a>'); |
| 90 | $this->assertStringNotContainsString('javascript:', $js, 'a javascript: href must not survive'); |
| 91 | $ok = sbr_kses_review_text('<a href="https://example.test/p" title="t">shop</a>'); |
| 92 | $this->assertStringContainsString('https://example.test/p', $ok, 'a normal link must survive'); |
| 93 | |
| 94 | // Attributes must go too, not just disallowed tags. An allowlist entry with an |
| 95 | // empty attribute array means "this tag, bare" — a span that keeps an event |
| 96 | // handler is a sink even though the tag itself is permitted. |
| 97 | $span = sbr_kses_review_text('<span onmouseover="alert(1)" style="x">hi</span>'); |
| 98 | $this->assertStringContainsString('hi', $span); |
| 99 | $this->assertStringNotContainsString('onmouseover', $span); |
| 100 | $this->assertStringNotContainsString('style', $span); |
| 101 | } |
| 102 | |
| 103 | public function test_review_text_allowlist_keeps_the_formatting_reviews_actually_use(): void |
| 104 | { |
| 105 | // nl2br() output and light emphasis are what the templates rely on. Narrowing |
| 106 | // the allowlist must not cost line breaks — that would be a visible regression |
| 107 | // on every multi-paragraph review. |
| 108 | $out = sbr_kses_review_text('First line.<br />Second line. <strong>great</strong> and <em>lovely</em>'); |
| 109 | |
| 110 | $this->assertStringContainsString('<br', $out); |
| 111 | $this->assertStringContainsString('<strong>great</strong>', $out); |
| 112 | $this->assertStringContainsString('<em>lovely</em>', $out); |
| 113 | } |
| 114 | |
| 115 | public function test_review_text_allowlist_handles_non_string_input(): void |
| 116 | { |
| 117 | $this->assertSame('', sbr_kses_review_text(null)); |
| 118 | $this->assertSame('', sbr_kses_review_text('')); |
| 119 | $this->assertSame('', sbr_kses_review_text([])); |
| 120 | } |
| 121 | |
| 122 | public function test_review_text_allowlist_ignores_a_non_array_filter_return(): void |
| 123 | { |
| 124 | // wp_kses() treats a STRING second argument as a CONTEXT NAME, so a filter |
| 125 | // returning 'post' resolves $allowedposttags — which keeps <img class src alt> |
| 126 | // and re-opens this exact chain. Verified against WordPress: |
| 127 | // wp_kses('<img class="emoji" src="x" alt="pwn">', 'post') returns it intact. |
| 128 | // Caught in review. The helper must fall back to its own default instead. |
| 129 | // add_filter() is a no-op in this harness; the bootstrap injects filter returns |
| 130 | // through $wp_filter_mock instead. |
| 131 | global $wp_filter_mock; |
| 132 | |
| 133 | $baseline = sbr_kses_review_text(self::EMOJI_PAYLOAD); |
| 134 | $this->assertStringNotContainsString('<img', $baseline); |
| 135 | |
| 136 | foreach (['post', 'strip', '', 0] as $bad) { |
| 137 | $wp_filter_mock['sbr_allowed_review_text_tags'] = $bad; |
| 138 | try { |
| 139 | $out = sbr_kses_review_text(self::EMOJI_PAYLOAD); |
| 140 | } finally { |
| 141 | unset($wp_filter_mock['sbr_allowed_review_text_tags']); |
| 142 | } |
| 143 | |
| 144 | // Compared against the UNFILTERED output rather than against a hardcoded |
| 145 | // expectation, so the assertion holds whatever the harness's wp_kses() |
| 146 | // does with a string second argument. What it pins is ours: a non-array |
| 147 | // return must be discarded, leaving behaviour identical to no filter. |
| 148 | $this->assertSame( |
| 149 | $baseline, |
| 150 | $out, |
| 151 | 'a non-array filter return must be discarded, not passed to wp_kses() as a context' |
| 152 | ); |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | public function test_review_text_allowlist_does_not_allow_target(): void |
| 157 | { |
| 158 | // target without a forced rel="noopener" hands the opened page a window.opener |
| 159 | // handle, and a review body has no reason to retarget the window. WordPress's |
| 160 | // own comment allowlist omits it too. |
| 161 | $out = sbr_kses_review_text('<a href="https://example.test" target="_blank">x</a>'); |
| 162 | |
| 163 | $this->assertStringContainsString('https://example.test', $out); |
| 164 | $this->assertStringNotContainsString('target', $out); |
| 165 | } |
| 166 | |
| 167 | // ---------- step 2: the read path no longer re-arms encoded markup ---------- |
| 168 | |
| 169 | public function test_get_text_does_not_re_decode_stored_markup(): void |
| 170 | { |
| 171 | // Entities are decoded ONCE, at ingest. get_text() used to decode a SECOND |
| 172 | // time, which re-armed a stored `<img …>` into live markup after the |
| 173 | // write path had already accepted it as inert text — that second decode is |
| 174 | // what made the double-encoded payload work. |
| 175 | // |
| 176 | // This is a regression guard: re-introducing the decode here re-opens the |
| 177 | // vector for every writer that isn't covered on the write side, and there are |
| 178 | // several (Woo/EDD pass comment_content straight into $review['text']). |
| 179 | $parser = new Parser(); |
| 180 | $out = $parser->get_text(['text' => '<img src=x onerror=alert(1)> hello']); |
| 181 | |
| 182 | $this->assertStringNotContainsString('<img', $out, 'get_text() must not re-decode stored markup'); |
| 183 | $this->assertStringContainsString('<img', $out, 'the encoded form must survive verbatim'); |
| 184 | |
| 185 | // And even if it somehow did arrive decoded, the render allowlist is the |
| 186 | // second, independent layer that disarms it. Both must hold. |
| 187 | $rendered = sbr_kses_review_text('<img src=x onerror=alert(1)> hello'); |
| 188 | $this->assertStringNotContainsString('<img', $rendered); |
| 189 | $this->assertStringNotContainsString('onerror', $rendered); |
| 190 | $this->assertStringContainsString('hello', $rendered); |
| 191 | } |
| 192 | |
| 193 | public function test_get_reviewer_name_does_not_re_decode_either(): void |
| 194 | { |
| 195 | // Same contract as the body: decoded once at ingest, never again on read. |
| 196 | // The name is the more dangerous of the two because every consumer runs it |
| 197 | // through esc_html() — a second decode there produced live markup from a |
| 198 | // value the write path had neutralised. |
| 199 | $parser = new Parser(); |
| 200 | |
| 201 | $this->assertSame( |
| 202 | 'Søren Kjærgaard', |
| 203 | $parser->get_reviewer_name(['reviewer' => ['name' => 'Søren Kjærgaard']]), |
| 204 | 'get_reviewer_name() must return the stored value verbatim' |
| 205 | ); |
| 206 | // Real UTF-8, which is what the ingest decode actually produces, is untouched. |
| 207 | $this->assertSame( |
| 208 | 'Søren Kjærgaard', |
| 209 | $parser->get_reviewer_name(['reviewer' => ['name' => 'Søren Kjærgaard']]) |
| 210 | ); |
| 211 | $this->assertSame('', $parser->get_reviewer_name([])); |
| 212 | } |
| 213 | |
| 214 | public function test_every_template_escapes_the_reviewer_name(): void |
| 215 | { |
| 216 | // Globbed, not a hardcoded pair. The earlier version listed pro/author.php and |
| 217 | // lite/author.php by hand, so it structurally could not catch a third consumer — |
| 218 | // and there is one: pro/post-elements/media.php builds an aria-label from the |
| 219 | // name and escapes it with esc_attr(), which a same-line esc_html() rule would |
| 220 | // have failed. Caught in review. |
| 221 | $root = dirname(__DIR__, 2); |
| 222 | $files = $this->templateFiles($root . '/templates'); |
| 223 | $this->assertNotEmpty($files, 'no templates found — the glob is wrong'); |
| 224 | |
| 225 | $seen = 0; |
| 226 | foreach ($files as $path) { |
| 227 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local template on disk |
| 228 | $src = (string) file_get_contents($path); |
| 229 | if (strpos($src, 'get_reviewer_name') === false) { |
| 230 | continue; |
| 231 | } |
| 232 | $rel = str_replace($root . '/', '', $path); |
| 233 | |
| 234 | foreach (explode("\n", $src) as $lineNo => $line) { |
| 235 | if (strpos($line, 'get_reviewer_name') === false) { |
| 236 | continue; |
| 237 | } |
| 238 | $seen++; |
| 239 | |
| 240 | // esc_html for text context, esc_attr for attribute context. |
| 241 | if (preg_match('/esc_(html|attr)(_e|__)?\s*\(/', $line) === 1) { |
| 242 | continue; |
| 243 | } |
| 244 | |
| 245 | // Assign-then-escape is equally valid and is what media.php does: the |
| 246 | // name goes into $sb_media_label on one line and is escaped with |
| 247 | // esc_attr() on another. |
| 248 | // |
| 249 | // Deliberately strict about what counts. `=(?!=)` so a comparison |
| 250 | // (`==`, `!=`, `>=`) is not mistaken for an assignment, and the line |
| 251 | // must not itself emit — otherwise a template could echo the name raw |
| 252 | // on one line, call esc_attr() on the same variable somewhere else, and |
| 253 | // still pass the guard that exists to catch exactly that. |
| 254 | $assigns = preg_match('/(\$[A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/', $line, $assign) === 1; |
| 255 | $emits = preg_match('/(\becho\b|\bprint\b|<\?=)/', $line) === 1; |
| 256 | if ( |
| 257 | $assigns && ! $emits |
| 258 | && preg_match('/esc_(html|attr)(_e|__)?\s*\(\s*' . preg_quote($assign[1], '/') . '\b/', $src) === 1 |
| 259 | ) { |
| 260 | continue; |
| 261 | } |
| 262 | |
| 263 | $this->fail( |
| 264 | "{$rel} line " . ($lineNo + 1) . ' uses the reviewer name without esc_html()/esc_attr(),' |
| 265 | . ' directly or via an escaped variable' |
| 266 | ); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | $this->assertGreaterThan(0, $seen, 'no template renders the reviewer name — the check is vacuous'); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * @return list<string> |
| 275 | */ |
| 276 | private function templateFiles(string $dir): array |
| 277 | { |
| 278 | $out = []; |
| 279 | $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); |
| 280 | foreach ($it as $file) { |
| 281 | if ($file->isFile() && $file->getExtension() === 'php') { |
| 282 | $out[] = $file->getPathname(); |
| 283 | } |
| 284 | } |
| 285 | sort($out); |
| 286 | return $out; |
| 287 | } |
| 288 | |
| 289 | public function test_get_text_leaves_accented_characters_alone(): void |
| 290 | { |
| 291 | $parser = new Parser(); |
| 292 | |
| 293 | // This is the case the Feb-2026 Danish-characters fix was about, and the one |
| 294 | // the read-path decode removal must not regress: real UTF-8 in, real UTF-8 out. |
| 295 | // The ingest decode is what produces this shape, so it is what reaches render. |
| 296 | $this->assertSame( |
| 297 | 'Smørrebrød i København – café æøå', |
| 298 | $parser->get_text(['text' => 'Smørrebrød i København – café æøå']) |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | public function test_get_text_still_returns_empty_for_missing_body(): void |
| 303 | { |
| 304 | $parser = new Parser(); |
| 305 | $this->assertSame('', $parser->get_text([])); |
| 306 | $this->assertSame('', $parser->get_text(['text' => ''])); |
| 307 | } |
| 308 | |
| 309 | // ---------- step 1: the shortcode writer ---------- |
| 310 | |
| 311 | |
| 312 | |
| 313 | public function test_render_pipeline_keeps_danish_characters_readable(): void |
| 314 | { |
| 315 | // End-to-end BC for the Danish-character path. Two shapes have to survive the |
| 316 | // whole read+render chain, and they survive by different routes: |
| 317 | // |
| 318 | // real UTF-8 (what ingest stores today) — passes through untouched |
| 319 | // entity-encoded (rows cached before the Feb-2026 ingest decode landed in |
| 320 | // v2.4.5) — kses leaves a valid entity alone and the BROWSER renders it, so |
| 321 | // `smørrebrød` still displays as smørrebrød without the read |
| 322 | // decode. What it must never become is double-encoded (`&oslash;`), |
| 323 | // which is what would show the entity literally on screen. |
| 324 | $parser = new Parser(); |
| 325 | |
| 326 | $utf8 = sbr_kses_review_text(nl2br($parser->get_text([ |
| 327 | 'text' => 'Café & bar — smørrebrød', |
| 328 | ]))); |
| 329 | $this->assertStringContainsString('smørrebrød', $utf8); |
| 330 | |
| 331 | $encoded = sbr_kses_review_text(nl2br($parser->get_text([ |
| 332 | 'text' => 'Café & bar — smørrebrød', |
| 333 | ]))); |
| 334 | $this->assertStringContainsString('smørrebrød', $encoded); |
| 335 | $this->assertStringNotContainsString('&oslash;', $encoded, 'must not double-encode'); |
| 336 | } |
| 337 | |
| 338 | public function test_avatar_sanitiser_delegates_instead_of_hand_rolling(): void |
| 339 | { |
| 340 | // What this test can and cannot prove, stated plainly: the unit harness has no |
| 341 | // WordPress, so esc_url_raw() here is a stub. Asserting "javascript: is blocked" |
| 342 | // against it would only prove the stub blocks it. That security property belongs |
| 343 | // to WordPress and is verified out-of-band, against the real thing: |
| 344 | // |
| 345 | // wp eval: esc_url_raw('javascript:alert(1)') => '' |
| 346 | // esc_url_raw('data:text/html;base64,x') => '' |
| 347 | // esc_url_raw("java\tscript:alert(1)") => '' |
| 348 | // esc_url_raw('jav
ascript:alert(1)') => '' |
| 349 | // esc_url_raw('jav&#x0A;ascript:alert(1)')=> '' |
| 350 | // esc_url_raw('/wp-content/a.jpg') => '/wp-content/a.jpg' |
| 351 | // (.claude/bin/verify-smash-1795-wp reruns exactly these.) |
| 352 | // |
| 353 | // What IS ours to test is that we delegate rather than second-guessing: an |
| 354 | // earlier version of this helper hand-rolled a scheme regex and returned |
| 355 | // scheme-less values untouched, which let both entity-obfuscated forms through. |
| 356 | // So assert equivalence with the delegation, on payloads where a bypass branch |
| 357 | // would diverge. This fails against that earlier implementation. |
| 358 | $payloads = [ |
| 359 | 'https://example.test/a.png', |
| 360 | '/wp-content/uploads/a.jpg', |
| 361 | 'https://example.test/my%20photo.jpg', |
| 362 | 'javascript:alert(1)', |
| 363 | 'data:text/html;base64,x', |
| 364 | "java\tscript:alert(1)", |
| 365 | 'jav
ascript:alert(1)', |
| 366 | 'jav&#x0A;ascript:alert(1)', |
| 367 | 'wp-content/uploads/a.jpg', |
| 368 | ]; |
| 369 | |
| 370 | foreach ($payloads as $payload) { |
| 371 | $this->assertSame( |
| 372 | esc_url_raw(trim(wp_strip_all_tags($payload))), |
| 373 | Util::sanitize_avatar_url($payload), |
| 374 | "sanitize_avatar_url() must defer to esc_url_raw(), with no bypass branch: {$payload}" |
| 375 | ); |
| 376 | } |
| 377 | |
| 378 | // And the part that is genuinely ours: narrowing mixed to a string without a |
| 379 | // blind cast, so an array or object can never reach a sanitiser. |
| 380 | $this->assertSame('', Util::sanitize_avatar_url(['nope'])); |
| 381 | $this->assertSame('', Util::sanitize_avatar_url(null)); |
| 382 | $this->assertSame('', Util::sanitize_avatar_url(new \stdClass())); |
| 383 | } |
| 384 | |
| 385 | // ---------- the shortcode writer ---------- |
| 386 | |
| 387 | /** |
| 388 | * `[reviews-feed name="…" content="…"]` is Contributor-authorable and never |
| 389 | * reaches cache_single_review(), so it carries its own sanitisers. |
| 390 | * |
| 391 | * @param array<string,mixed> $atts |
| 392 | * @return array<string,mixed> |
| 393 | */ |
| 394 | private function shortcodeReview(array $atts): array |
| 395 | { |
| 396 | $service = (new \ReflectionClass(\SmashBalloon\Reviews\Common\Services\ShortcodeService::class)) |
| 397 | ->newInstanceWithoutConstructor(); |
| 398 | $settings = $service->get_single_manual_review_content($atts); |
| 399 | |
| 400 | return $settings['singleManualReviewContent']; |
| 401 | } |
| 402 | |
| 403 | public function test_shortcode_attributes_are_sanitised(): void |
| 404 | { |
| 405 | $out = $this->shortcodeReview([ |
| 406 | 'content' => '<img class="emoji" alt="<img src=x onerror=alert(1)>"> lovely', |
| 407 | 'name' => '<script>alert(1)</script>Mallory', |
| 408 | 'avatar' => 'javascript:alert(1)', |
| 409 | 'provider' => 'wordpress.org', |
| 410 | ]); |
| 411 | |
| 412 | $this->assertStringNotContainsString('<img', $out['content']); |
| 413 | $this->assertStringContainsString('lovely', $out['content']); |
| 414 | $this->assertStringNotContainsString('<script', $out['name']); |
| 415 | $this->assertStringNotContainsString('javascript:', $out['avatar']); |
| 416 | } |
| 417 | |
| 418 | public function test_shortcode_content_keeps_light_formatting(): void |
| 419 | { |
| 420 | // BC: sanitize_textarea_field() returns 'It was great and fast' — an existing |
| 421 | // testimonial silently loses its emphasis. Verified against WordPress. |
| 422 | // The render allowlist drops the <img> that carries the payload while keeping |
| 423 | // the formatting reviews actually use, so it is the right tool here. |
| 424 | $out = $this->shortcodeReview(['content' => 'It was <strong>great</strong> and <em>fast</em>']); |
| 425 | |
| 426 | $this->assertStringContainsString('<strong>great</strong>', $out['content']); |
| 427 | $this->assertStringContainsString('<em>fast</em>', $out['content']); |
| 428 | } |
| 429 | |
| 430 | public function test_shortcode_provider_slug_keeps_its_dot(): void |
| 431 | { |
| 432 | // sanitize_key('wordpress.org') === 'wordpressorg', which matches no provider: |
| 433 | // the slug is 'wordpress.org' verbatim in WordpressOrg::$name and in the |
| 434 | // literal comparisons at RemoteRequest:189, Util:274/:319 and |
| 435 | // SBR_Feed_Saver_Manager:743. Verified against WordPress. |
| 436 | $out = $this->shortcodeReview(['provider' => 'wordpress.org']); |
| 437 | |
| 438 | $this->assertSame('wordpress.org', $out['provider']); |
| 439 | } |
| 440 | |
| 441 | public function test_shortcode_provider_slug_is_lowercased(): void |
| 442 | { |
| 443 | // Every downstream provider check is a lowercase case-sensitive literal |
| 444 | // (Parser:303,309; Feed:786,798,905,1177; text.php:22,50) with no strtolower() |
| 445 | // on the path, so `provider="Google"` must not reach them as-is or it silently |
| 446 | // matches nothing. sanitize_key() lowercased; a plain sanitize_text_field() |
| 447 | // swap would have dropped that. Caught in review. |
| 448 | $this->assertSame('google', $this->shortcodeReview(['provider' => 'Google'])['provider']); |
| 449 | $this->assertSame('wordpress.org', $this->shortcodeReview(['provider' => 'WordPress.ORG'])['provider']); |
| 450 | } |
| 451 | |
| 452 | public function test_shortcode_provider_slug_cannot_traverse_the_icon_path(): void |
| 453 | { |
| 454 | // FeedDisplay::provider_icon_url() concatenates the provider straight into |
| 455 | // `assets/icons/{$provider}-provider.svg`, so a Contributor-authorable |
| 456 | // attribute reaching it unfiltered is an attacker-chosen <img src>. |
| 457 | // Caught in review. |
| 458 | foreach (['../../uploads/x', '../../../wp-config', 'a/../../b'] as $payload) { |
| 459 | $slug = $this->shortcodeReview(['provider' => $payload])['provider']; |
| 460 | $this->assertStringNotContainsString('/', $slug, "slash must not survive in `{$payload}`"); |
| 461 | $this->assertStringNotContainsString('..', $slug, "traversal must not survive in `{$payload}`"); |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | public function test_shortcode_preserves_facebook_rating_sentinels(): void |
| 466 | { |
| 467 | // absint('positive') === 0, and Parser::get_rating() maps a falsy rating to 1, |
| 468 | // so coercing the sentinel turns a 5-star recommendation into 1 star. |
| 469 | $this->assertSame('positive', $this->shortcodeReview(['rating' => 'positive'])['rating']); |
| 470 | $this->assertSame('negative', $this->shortcodeReview(['rating' => 'negative'])['rating']); |
| 471 | $this->assertSame(5, $this->shortcodeReview(['rating' => 5])['rating']); |
| 472 | } |
| 473 | |
| 474 | public function test_shortcode_rating_falls_back_to_absint_not_zero(): void |
| 475 | { |
| 476 | // Collapsing an unparseable rating to 0 is the SAME silent downgrade the |
| 477 | // sentinels above exist to prevent — Parser::get_rating() maps a falsy rating |
| 478 | // to 1 star — and absint() is what this replaced, so anything it used to |
| 479 | // salvage must still be salvaged. Caught in review. |
| 480 | $this->assertSame(3, $this->shortcodeReview(['rating' => '3 stars'])['rating']); |
| 481 | // is_numeric('5 ') is FALSE on PHP 7.4, the declared floor, so a trailing space |
| 482 | // takes the fallback branch there and must not become 1 star. |
| 483 | $this->assertSame(5, (int) $this->shortcodeReview(['rating' => '5 '])['rating']); |
| 484 | // Genuinely unusable input still lands on 0, exactly as absint() always did. |
| 485 | $this->assertSame(0, $this->shortcodeReview(['rating' => 'lovely'])['rating']); |
| 486 | } |
| 487 | |
| 488 | public function test_shortcode_keeps_a_date_string_time(): void |
| 489 | { |
| 490 | // A non-numeric time is a supported shape — the attribute is free-form and |
| 491 | // FeedDisplay drops the date element entirely on a falsy value, so absint() |
| 492 | // would make the date disappear rather than merely look odd. |
| 493 | $this->assertSame('2024-06-01', $this->shortcodeReview(['time' => '2024-06-01'])['time']); |
| 494 | $this->assertSame(1600000000, $this->shortcodeReview(['time' => 1600000000])['time']); |
| 495 | } |
| 496 | |
| 497 | public function test_shortcode_avatar_is_url_validated(): void |
| 498 | { |
| 499 | $this->assertSame( |
| 500 | 'https://example.test/a.png', |
| 501 | $this->shortcodeReview(['avatar' => 'https://example.test/a.png'])['avatar'] |
| 502 | ); |
| 503 | $this->assertSame('', $this->shortcodeReview(['avatar' => 'javascript:alert(1)'])['avatar']); |
| 504 | } |
| 505 | |
| 506 | public function test_shortcode_omitted_attributes_stay_false(): void |
| 507 | { |
| 508 | // The `false` sentinel is the pre-existing contract for an absent attribute; |
| 509 | // sanitising must not turn it into '' or 0. |
| 510 | $out = $this->shortcodeReview([]); |
| 511 | |
| 512 | foreach (['name', 'content', 'rating', 'avatar', 'time', 'provider'] as $key) { |
| 513 | $this->assertFalse($out[$key], "an omitted `{$key}` must stay false"); |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | // ---------- the review-form writer ---------- |
| 518 | |
| 519 | public function test_submission_content_is_stored_byte_identically_and_renders_inert(): void |
| 520 | { |
| 521 | // Two things have to hold at once here, and an interim version of this fix |
| 522 | // traded the first away for the second. |
| 523 | // |
| 524 | // BC: {prefix}sbr_form_submissions.content is read as PLAIN TEXT by two |
| 525 | // consumers, so its stored BYTES must not shift. |
| 526 | // FormRulesManager::sql_passes_rule():212 builds `AND LENGTH(content) >= N` |
| 527 | // for the auto-approve / auto-archive rules, re-evaluated against EXISTING |
| 528 | // rows whenever a rule changes — a length shift moves reviews across a |
| 529 | // customer's threshold; |
| 530 | // SubmissionsManager::transform_to_review() takes substr($content, 0, 40) as |
| 531 | // the review title (cited by name, not line: it is in the same file as the |
| 532 | // writer under test and shifts every time that file is edited). |
| 533 | // Adding html_entity_decode() here moved `Fish & Chips` from 16 bytes to 12 |
| 534 | // and `<strong>bold</strong> text` by +17. So the writer is left ALONE. |
| 535 | // |
| 536 | // Security: that is safe because nothing decodes on the way out any more, so the |
| 537 | // encoded payload never becomes an element. Both halves are asserted. |
| 538 | $store = static function ($content): string { |
| 539 | $out = \SmashBalloon\Reviews\Pro\Integrations\Forms\SubmissionsManager::get_db_store_data( |
| 540 | [ |
| 541 | 'submission_id' => 'abc', |
| 542 | 'form_id' => 1, |
| 543 | 'rating' => 5, |
| 544 | 'content' => $content, |
| 545 | 'date' => 1600000000, |
| 546 | 'json_data' => [], |
| 547 | 'used_in' => [], |
| 548 | 'archived_in' => [], |
| 549 | 'deleted_in' => [], |
| 550 | ], |
| 551 | ['plugin' => 'wpforms', 'id' => 1] |
| 552 | ); |
| 553 | return $out['content']; |
| 554 | }; |
| 555 | |
| 556 | // BC — byte-identical to sanitize_text_field(), entity-bearing cases included. |
| 557 | // Entity-free inputs alone would be tautological on old and new code. |
| 558 | $inputs = [ |
| 559 | 'Great stay, would come again', |
| 560 | 'Fish & Chips', |
| 561 | 'Fish & Chips', |
| 562 | 'I love <3 this place!', |
| 563 | 'price < 20 and > 5', |
| 564 | 'Café smørrebrød', |
| 565 | '<strong>bold</strong> text', |
| 566 | '<img class="emoji" alt="&lt;script&gt;"> nice', |
| 567 | ]; |
| 568 | foreach ($inputs as $in) { |
| 569 | $this->assertSame( |
| 570 | sanitize_text_field($in), |
| 571 | $store($in), |
| 572 | "stored bytes must not shift for: {$in}" |
| 573 | ); |
| 574 | } |
| 575 | |
| 576 | // Security — the encoded payload survives storage but is inert once read and |
| 577 | // rendered, because Parser::get_text() no longer decodes and the allowlist |
| 578 | // leaves a valid entity as an entity. |
| 579 | $parser = new Parser(); |
| 580 | foreach ( |
| 581 | ['<img class="emoji" alt="&lt;script&gt;alert(1)&lt;/script&gt;"> great place', |
| 582 | '<script>alert(1)</script> ok'] as $payload |
| 583 | ) { |
| 584 | $rendered = sbr_kses_review_text(nl2br($parser->get_text(['text' => $store($payload)]))); |
| 585 | $this->assertStringNotContainsString('<img', $rendered, 'no element may be built from a stored submission'); |
| 586 | $this->assertStringNotContainsString('<script', $rendered); |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | // ---------- the JS half, pinned as a file assertion ---------- |
| 591 | |
| 592 | public function test_feed_script_and_its_min_twin_never_parse_the_emoji_alt(): void |
| 593 | { |
| 594 | // The .min is what the front end loads and there is no build step, so the two |
| 595 | // files are hand-synced and drift silently. Assert on both. |
| 596 | // |
| 597 | // An earlier version of this guard was VACUOUS and passed against un-fixed |
| 598 | // code: it looked for the bare word "createTextNode", which already appears in |
| 599 | // an unrelated stylesheet helper, and its negative regex used `\w*` where the |
| 600 | // real line has `$(this)` — `$` is not a word character, so it never matched. |
| 601 | // Caught in review. Both assertions below were re-checked against the pre-fix |
| 602 | // file and do fail on it. |
| 603 | $root = dirname(__DIR__, 2); |
| 604 | |
| 605 | foreach (['assets/js/sbr-feed.js', 'assets/js/sbr-feed.min.js'] as $rel) { |
| 606 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local asset on disk |
| 607 | $js = (string) file_get_contents($root . '/' . $rel); |
| 608 | |
| 609 | // The dangerous shape: the alt handed straight to replaceWith(), in either |
| 610 | // the source (`$(this)`) or minified (`t(this)`) form. |
| 611 | $this->assertDoesNotMatchRegularExpression( |
| 612 | '/replaceWith\(\s*[\w$]+\(this\)\.attr\(\s*[\x27"]alt[\x27"]\s*\)\s*\)/', |
| 613 | $js, |
| 614 | "{$rel} passes the raw emoji alt to replaceWith() — jQuery parses it as HTML" |
| 615 | ); |
| 616 | |
| 617 | // And the safe shape must actually be present on that same call. |
| 618 | $this->assertMatchesRegularExpression( |
| 619 | '/replaceWith\(\s*document\.createTextNode\(/', |
| 620 | $js, |
| 621 | "{$rel} must build a text node from the emoji alt" |
| 622 | ); |
| 623 | |
| 624 | // The expand path must never blank the card. `.empty()` with no cached nodes |
| 625 | // wipes the review body, where the old `.html(undefined)` was a jQuery getter |
| 626 | // and a harmless no-op. jQuery .data() does not survive a .clone() without |
| 627 | // withDataAndEvents — owl-carousel loop clones — so the nodes can legitimately |
| 628 | // be absent. Caught in review. |
| 629 | $this->assertMatchesRegularExpression( |
| 630 | '/if\s*\([^)]*!\s*[\w$]+\.length\s*\)\s*\{?\s*return/', |
| 631 | $js, |
| 632 | "{$rel} must bail instead of emptying the caption when the cached nodes are missing" |
| 633 | ); |
| 634 | $this->assertDoesNotMatchRegularExpression( |
| 635 | '/empty\(\)\.append\([^)]*\?[^)]*\.clone\(\)\s*:\s*\[\]\s*\)/', |
| 636 | $js, |
| 637 | "{$rel} still uses the ternary that appends nothing — that blanks the review" |
| 638 | ); |
| 639 | |
| 640 | // ORDER matters, not just presence: the bail must come before ANY DOM |
| 641 | // mutation in the handler. Otherwise the missing-nodes path tears something |
| 642 | // down and then returns, and the review is left permanently truncated. |
| 643 | // |
| 644 | // Asserted structurally and deliberately loosely: |
| 645 | // - anchored on the BINDING selector (the bare class also appears in a |
| 646 | // commented-out block earlier in the file); |
| 647 | // - `//` comments stripped first, or the explanatory comment above the |
| 648 | // guard — which mentions `.empty()` — would itself trip the check; |
| 649 | // - the `.sbr-expand` selector is NOT named. It is a known typo (the real |
| 650 | // class is `sb-expand`) and pinning it would make fixing it fail here; |
| 651 | // - no minified identifier is hard-coded, so a re-minify still passes. |
| 652 | $handlerAt = strpos($js, 'sb-expand button.sb-expand-on-click'); |
| 653 | $this->assertNotFalse($handlerAt, "{$rel} no longer binds the read-more handler"); |
| 654 | $handler = (string) preg_replace('#//[^\n]*#', '', substr($js, $handlerAt, 2500)); |
| 655 | |
| 656 | $guardPattern = '/!\s*[\w$]+\.length\s*\)\s*\{?\s*return/'; |
| 657 | $this->assertMatchesRegularExpression( |
| 658 | $guardPattern, |
| 659 | $handler, |
| 660 | "{$rel} read-more handler has no missing-nodes bail" |
| 661 | ); |
| 662 | preg_match($guardPattern, $handler, $m, PREG_OFFSET_CAPTURE); |
| 663 | $guardAt = $m[0][1]; |
| 664 | |
| 665 | foreach (['.empty(', '.remove('] as $mutation) { |
| 666 | $at = strpos($handler, $mutation); |
| 667 | if ($at === false) { |
| 668 | continue; |
| 669 | } |
| 670 | $this->assertLessThan( |
| 671 | $at, |
| 672 | $guardAt, |
| 673 | "{$rel} read-more handler calls {$mutation} before bailing — on the missing-nodes path that mutates the DOM and then returns" |
| 674 | ); |
| 675 | } |
| 676 | |
| 677 | // The read-more round-trip is the SECOND re-parse sink on this file: it |
| 678 | // used to serialise the body into a data-text attribute and expand it with |
| 679 | // .html(), which re-parses. It now keeps detached DOM nodes instead, so |
| 680 | // neither the attribute write nor the .html() read may come back. |
| 681 | // Strict substring, not a narrow regex. A loosened version would miss a |
| 682 | // reintroduction via dataset.text, a concatenated attribute name, or a |
| 683 | // data-text emitted from a PHP template. Nothing in either file mentions |
| 684 | // the string any more, so there is no reason to weaken it. |
| 685 | $this->assertStringNotContainsString( |
| 686 | 'data-text', |
| 687 | $js, |
| 688 | "{$rel} must not round-trip the review body through a data-text attribute" |
| 689 | ); |
| 690 | $this->assertMatchesRegularExpression( |
| 691 | '/sbrFullText/', |
| 692 | $js, |
| 693 | "{$rel} must keep the full review body as detached nodes" |
| 694 | ); |
| 695 | } |
| 696 | } |
| 697 | } |
| 698 |