PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.10.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / tests / Unit / Smash1795ReviewTextXssTest.php
reviews-feed / tests / Unit Last commit date
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="&lt;img src=x onerror=alert(1)&gt;"> 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="&lt;img src=x onerror=alert(1)&gt;"> hi');
77 $this->assertStringNotContainsString('<', $evil, 'the resolved alt must contain no live markup at all');
78 $this->assertStringContainsString('&lt;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 `&lt;img …&gt;` 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' => '&lt;img src=x onerror=alert(1)&gt; hello']);
181
182 $this->assertStringNotContainsString('<img', $out, 'get_text() must not re-decode stored markup');
183 $this->assertStringContainsString('&lt;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&oslash;ren Kj&aelig;rgaard',
203 $parser->get_reviewer_name(['reviewer' => ['name' => 'S&oslash;ren Kj&aelig;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&oslash;rrebr&oslash;d` still displays as smørrebrød without the read
322 // decode. What it must never become is double-encoded (`&amp;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&eacute; &amp; bar &mdash; sm&oslash;rrebr&oslash;d',
333 ])));
334 $this->assertStringContainsString('sm&oslash;rrebr&oslash;d', $encoded);
335 $this->assertStringNotContainsString('&amp;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&#x0A;ascript:alert(1)') => ''
349 // esc_url_raw('jav&amp;#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&#x0A;ascript:alert(1)',
366 'jav&amp;#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="&lt;img src=x onerror=alert(1)&gt;"> 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 &amp; 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 &amp; Chips',
561 'Fish & Chips',
562 'I love &lt;3 this place!',
563 'price &lt; 20 and &gt; 5',
564 'Caf&eacute; sm&oslash;rrebr&oslash;d',
565 '<strong>bold</strong> text',
566 '&lt;img class="emoji" alt="&amp;lt;script&amp;gt;"&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 ['&lt;img class="emoji" alt="&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"&gt; great place',
582 '&lt;script&gt;alert(1)&lt;/script&gt; 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