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 `[` / |
| 23 | * `]` back to `[` / `]`, re-arming the shortcode. Hex entities (`[` / |
| 24 | * `]`) 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;`). |
| 122 | $this->assertStringNotContainsString('&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 | * `[` / `]` 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 `[` / `]` 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( '[', ']' ), 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 `[` / `]` 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('[', $out); |
| 197 | $this->assertStringContainsString(']', $out); |
| 198 | $this->assertStringNotContainsString('[', $out); |
| 199 | $this->assertStringNotContainsString(']', $out); |
| 200 | } |
| 201 | } |
| 202 |