PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.6.7
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.6.7
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 / ShortcodeNeutralizationTest.php
reviews-feed / tests / Unit Last commit date
Doubles 2 months ago Providers 2 months ago BulkReviewsUpdateStuckStateTest.php 2 months ago ClearCacheRelayResetTest.php 2 months ago DeleteSourceRelayFailureTest.php 2 months ago ErrorHandlerFalsyOptionTest.php 2 months ago FeedCacheUpdateServiceTest.php 2 months ago FeedMalformedPayloadTest.php 2 months ago ForceKeylessRefetchTest.php 2 months ago LicenseDeactivateStaleStateTest.php 2 months ago MediaFinderMemoTest.php 2 months ago MultiSourceAggregationTest.php 2 months ago ReconcileMigratedLicenseRoutineTest.php 2 months ago ReconcileRemovalTest.php 2 months ago RegisterWebsiteRoutineTest.php 2 months ago RemoteRequestMemoTest.php 2 months ago ReviewAlertHeaderTotalsTest.php 2 months ago ReviewAlertPageTargetingTest.php 2 months ago ReviewAlertStarFillTest.php 2 months ago ShortcodeNeutralizationTest.php 2 months ago SiteMigrationRecoveryTest.php 2 months ago Smash1583HeaderParityTest.php 2 months ago SourceIdLookupTest.php 2 months ago WpmlGetCurrentLanguageTest.php 2 months ago WpmlLanguageMappingTest.php 2 months ago
ShortcodeNeutralizationTest.php
202 lines
1 <?php
2
3 namespace SmashBalloon\Reviews\Tests\Unit;
4
5 use PHPUnit\Framework\TestCase;
6
7 /**
8 * Regression coverage for CVE-2026-10724 / SMASH-1607.
9 *
10 * Review data imported from connected sources is rendered inside the dynamic
11 * `sbr/sbr-feed-block`. WordPress runs `do_blocks()` on `the_content` at
12 * priority 9 and `do_shortcode()` at priority 11, so any shortcode bracket left
13 * in the rendered block markup is expanded server-side — even one an
14 * unauthenticated visitor planted in a public review. `sbr_neutralize_shortcodes()`
15 * encodes the `[` / `]` characters so `do_shortcode()` can never match them,
16 * while preserving the literal text for the visitor.
17 *
18 * The security invariant under test has two parts: (1) after neutralization, NO
19 * literal `[` or `]` survives in the output; and (2) the output must STILL be
20 * bracket-free after WordPress core's `unescape_invalid_shortcodes()` runs over
21 * it (`do_shortcode()` calls it on every processed content). That second part is
22 * the one the original decimal implementation failed: core reverses `&#91;` /
23 * `&#93;` back to `[` / `]`, re-arming the shortcode. Hex entities (`&#x5B;` /
24 * `&#x5D;`) are not reversed, so they hold. `do_shortcode()` requires a literal
25 * `[` to match, so an output with none — before AND after core's unescape — is
26 * unreachable by the shortcode parser regardless of registered shortcodes.
27 */
28 final class ShortcodeNeutralizationTest extends TestCase
29 {
30 public static function setUpBeforeClass(): void
31 {
32 // `esc_html()` is the WordPress escaper used at the reviewer-name / title
33 // sinks. It intentionally leaves `[` and `]` untouched (it only encodes
34 // `< > & " '`), which is exactly why the neutralizer is needed on top of
35 // it. The stub mirrors that behaviour so the ordering test is faithful.
36 if (! function_exists('esc_html')) {
37 function esc_html($text)
38 {
39 return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8');
40 }
41 }
42
43 require_once dirname(__DIR__, 2) . '/class/sbr-functions.php';
44 }
45
46 /**
47 * The exact proof-of-concept payload from the security report must be
48 * rendered inert: no literal brackets survive, so `do_shortcode()` cannot
49 * expand it.
50 */
51 public function testReportProofOfConceptIsNeutralized(): void
52 {
53 $payload = 'Great service! [gallery ids=1]';
54
55 $out = \sbr_neutralize_shortcodes($payload);
56
57 $this->assertStringNotContainsString('[', $out);
58 $this->assertStringNotContainsString(']', $out);
59 $this->assertStringNotContainsString('[gallery', $out);
60 }
61
62 /**
63 * The literal text the reviewer typed is preserved for the visitor — the
64 * browser decodes the numeric entities back to the original characters.
65 * This is why bracket-encoding is preferred over `strip_shortcodes()`,
66 * which would silently delete the reviewer's content.
67 */
68 public function testVisibleTextIsPreserved(): void
69 {
70 $payload = 'Great service! [gallery ids=1]';
71
72 $decoded = html_entity_decode(
73 \sbr_neutralize_shortcodes($payload),
74 ENT_QUOTES | ENT_HTML5,
75 'UTF-8'
76 );
77
78 $this->assertSame($payload, $decoded);
79 }
80
81 /**
82 * Opening tags, self-closing tags, closing tags and multiple shortcodes in
83 * one string are all covered — the parser never sees a `[` it can latch on.
84 */
85 public function testAllShortcodeShapesAreNeutralized(): void
86 {
87 $payloads = array(
88 '[gallery]',
89 '[gallery ids=1]',
90 '[caption]x[/caption]',
91 'nested [a][b]c[/b][/a] text',
92 '[wp_head]',
93 'lots ] of ] brackets [ and [ more',
94 );
95
96 foreach ($payloads as $payload) {
97 $out = \sbr_neutralize_shortcodes($payload);
98 $this->assertStringNotContainsString('[', $out, "Left bracket survived for: {$payload}");
99 $this->assertStringNotContainsString(']', $out, "Right bracket survived for: {$payload}");
100 }
101 }
102
103 /**
104 * The reviewer NAME is the most attacker-controlled field (an
105 * unauthenticated reviewer fully controls their display name) and is only
106 * `esc_html()`-escaped at the author-template sinks. Applying the
107 * neutralizer as the outermost wrapper on the escaped output must strip the
108 * vector without double-encoding the entities esc_html already produced.
109 */
110 public function testReviewerNameSinkOrderingIsSafe(): void
111 {
112 $evilName = 'A & B "Co" [gallery ids=1]';
113
114 // Mirrors the template: sbr_neutralize_shortcodes( esc_html( $name ) ).
115 $out = \sbr_neutralize_shortcodes(esc_html($evilName));
116
117 // Shortcode parser can't match — no literal brackets.
118 $this->assertStringNotContainsString('[', $out);
119 $this->assertStringNotContainsString(']', $out);
120
121 // No double-encoding of esc_html's entities (no `&amp;amp;`).
122 $this->assertStringNotContainsString('&amp;amp;', $out);
123
124 // Browser-visible text is faithful to the reviewer's input.
125 $decoded = html_entity_decode($out, ENT_QUOTES | ENT_HTML5, 'UTF-8');
126 $this->assertSame($evilName, $decoded);
127 }
128
129 /**
130 * Output that already passed through `wp_kses_post()` (review text / pros /
131 * cons) still carries literal brackets; the neutralizer is applied
132 * outermost. Simulated here by feeding bracket-bearing markup directly.
133 */
134 public function testKsesStyleOutputIsNeutralizedOutermost(): void
135 {
136 $ksesOutput = 'Loved it<br />[contact-form-7 id="42"]';
137
138 $out = \sbr_neutralize_shortcodes($ksesOutput);
139
140 $this->assertStringNotContainsString('[', $out);
141 $this->assertStringNotContainsString(']', $out);
142 // Surrounding safe markup is untouched.
143 $this->assertStringContainsString('<br />', $out);
144 }
145
146 /**
147 * Plain content without brackets is returned byte-for-byte unchanged.
148 */
149 public function testPlainTextIsUnchanged(): void
150 {
151 $text = 'Just a normal, friendly review. 5 stars!';
152 $this->assertSame($text, \sbr_neutralize_shortcodes($text));
153 }
154
155 /**
156 * Empty string and non-string inputs are handled defensively.
157 */
158 public function testEdgeCaseInputs(): void
159 {
160 $this->assertSame('', \sbr_neutralize_shortcodes(''));
161 $this->assertNull(\sbr_neutralize_shortcodes(null));
162 }
163
164 /**
165 * THE regression for CVE-2026-10724: the neutralized output must survive
166 * WordPress core's `unescape_invalid_shortcodes()`, which `do_shortcode()`
167 * runs over every processed content. Core reverses the DECIMAL entities
168 * `&#91;` / `&#93;` back to raw `[` / `]` — silently undoing a decimal-based
169 * neutralizer and re-arming the planted shortcode. This test replicates that
170 * core function verbatim and asserts no bracket reappears; it FAILS against
171 * the original `&#91;` / `&#93;` implementation and passes with hex.
172 */
173 public function testSurvivesWordPressUnescapeInvalidShortcodes(): void
174 {
175 $payload = 'Great service! [gallery ids=1]';
176
177 $out = \sbr_neutralize_shortcodes(esc_html($payload));
178
179 // Verbatim from wp-includes/shortcodes.php::unescape_invalid_shortcodes().
180 $afterCore = str_replace(array( '&#91;', '&#93;' ), array( '[', ']' ), $out);
181
182 $this->assertStringNotContainsString('[', $afterCore, 'Literal "[" reappeared after WP core unescape_invalid_shortcodes() — the neutralizer must use hex entities, not decimal.');
183 $this->assertStringNotContainsString(']', $afterCore);
184 $this->assertStringNotContainsString('[gallery', $afterCore);
185 }
186
187 /**
188 * Pins the encoding to HEX entities. Decimal `&#91;` / `&#93;` is forbidden
189 * because WP core unescapes it (see test above); this guards against a
190 * regression back to decimal.
191 */
192 public function testUsesHexEntitiesNotDecimal(): void
193 {
194 $out = \sbr_neutralize_shortcodes('[x]');
195
196 $this->assertStringContainsString('&#x5B;', $out);
197 $this->assertStringContainsString('&#x5D;', $out);
198 $this->assertStringNotContainsString('&#91;', $out);
199 $this->assertStringNotContainsString('&#93;', $out);
200 }
201 }
202