PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.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 / ShortcodeNeutralizationTest.php
reviews-feed / tests / Unit Last commit date
Doubles 1 week ago Providers 1 week ago BulkRearmOnGrowthTest.php 1 week ago BulkReviewsUpdateStuckStateTest.php 1 week ago ClearCacheRelayResetTest.php 1 week ago DeleteSourceRelayFailureTest.php 1 week ago ErrorHandlerFalsyOptionTest.php 1 week ago FeedCacheUpdateServiceTest.php 1 week ago FeedMalformedPayloadTest.php 1 week ago ForceKeylessRefetchTest.php 1 week ago LicenseDeactivateStaleStateTest.php 1 week ago MediaFinderMemoTest.php 1 week ago MultiSourceAggregationTest.php 1 week ago ReconcileMigratedLicenseRoutineTest.php 1 week ago ReconcileRemovalTest.php 1 week ago RegisterWebsiteRoutineTest.php 1 week ago RelaySlowEndpointsTest.php 1 week ago RemoteRequestMemoTest.php 1 week ago ReviewAlertHeaderTotalsTest.php 1 week ago ReviewAlertPageTargetingTest.php 1 week ago ReviewAlertStarFillTest.php 1 week ago ShortcodeNeutralizationTest.php 1 week ago SiteMigrationRecoveryTest.php 1 week ago Smash1130UsageTrackingHardeningTest.php 1 week ago Smash1130UsageTrackingHooksTest.php 1 week ago Smash1583HeaderParityTest.php 1 week ago Smash1631MultiLanguageBulkTest.php 1 week ago Smash1631UpdateSingleLangScopeTest.php 1 week ago Smash1706TripAdvisorPlaceIdTest.php 1 week ago Smash1756SchemaServiceTest.php 1 week ago Smash1785AvatarLocalUrlGuardTest.php 1 week ago Smash1785AvatarReHealTest.php 1 week ago Smash1795ReviewTextXssTest.php 1 week ago Smash1835TripAdvisorKeyShapeTest.php 1 week ago Smash1973WordpressOrgPlaceIdNullTest.php 1 week ago Smash1987NestedSourceErrorShapeTest.php 1 week ago Smash782BookingHeaderRatingTest.php 1 week ago Smash782CountryFlagEmojiTest.php 1 week ago Smash782ExternalRefreshCronTest.php 1 week ago Smash782ExtrasTemplateTest.php 1 week ago Smash782ReviewAlertProviderDataTest.php 1 week ago SourceIdLookupTest.php 1 week ago WpmlGetCurrentLanguageTest.php 1 week ago WpmlLanguageMappingTest.php 1 week 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