Skip to content

Improved security for dynamic content, stored CSS, and block conditions. - #3110

Open
girishpanchal30 wants to merge 11 commits into
developmentfrom
bugfix/otter-internals/316
Open

girishpanchal30 wants to merge 11 commits into
developmentfrom
bugfix/otter-internals/316

Conversation

@girishpanchal30

Copy link
Copy Markdown
Contributor

Summary

1. Unauthenticated stored XSS via dynamic-content rewrites

The render_block filters that rewrite #otterDynamicLink, /otter/v1/dynamic image URLs and {otterDynamic?…} magic tags run on every block, including core blocks. Their regex tail (\/?.[^"]*) could consume the quote that closes an HTML attribute.

Text printed straight after a token then became live attributes. A visitor's comment author name in the Latest Comments block was enough, with no account needed. Contributors could do the same in their own content.

Fix:

  • Token tails now stop at ", ', whitespace, < and >; magic tags require ? and stop at }.
  • Link-button values go through esc_url(), as the sibling apply_link() already did. Pro's post-meta link returned raw meta.

2. Contributor stored XSS via block style values

save_css_file() strips tags with kses, then compress() runs htmlspecialchars_decode(). That turned entity-encoded markup in any style attribute, on any block, back into a real closing </style>, which was then printed raw inside <style>.

Fix: new CSS_Handler::escape_style_contents() rewrites </style to the CSS escape \3c\2fstyle, the same escape core's HTML API applies, inlined because it is WP 6.7+ only. It runs:

  • after the decode in compress();
  • where stored post, reusable-block and widget CSS is printed, so CSS saved before this fix is covered too.

The decode stays: kses encodes >, & and < that valid CSS needs (child combinators, URLs, media ranges), and a test guards that.

3. Unauthenticated disclosure via otter/v1/dynamic routes

check_permission() treated any post with publish status as public. That status is also used by password-protected posts and non-viewable post types (synced patterns, templates, navigation, WooCommerce coupons).

The image route also loaded any product by its id parameter without checking it.

Fix:

  • edit_post users keep full access.
  • Everyone else needs a published post that passes is_post_type_viewable() and ! post_password_required(). A visitor who has entered the post password still passes.
  • The product id gets the same check.
  • Pro no longer fatals when id isn't a product.

4. Unauthenticated disclosure of condition-restricted blocks

Block Conditions skipped evaluation whenever REST_REQUEST was set or is_admin() was true. So GET /wp/v2/posts/<id> returned every restricted block in content.rendered and excerpt.rendered. Front-end admin-ajax "load more" requests did the same, because is_admin() is true there too.

Fix: conditions are now skipped only on:

  • real wp-admin screens (is_admin() && ! wp_doing_ajax());
  • REST dispatches that are context=edit and come from a user with edit_posts.

The REST state is a stack pushed and popped in rest_request_before_callbacks / rest_request_after_callbacks. The capability check is required because those filters run before the route's own permission check.

5. Contributor stored XSS via Atomic Wind CSS

POST otter/v1/atomic-wind/style stores CSS for anyone who can edit the post. Its sanitizer stripped style and script tags in a single pass, so nested tags reassembled into a closing </style>.

WordPress 6.6–6.9 print wp_add_inline_style() content raw, so this was exploitable there. Newer cores escape it.

Fix: escape_style_contents() runs on save and whenever cached CSS is read.

Behaviour changes to be aware of

  • REST clients reading posts in view context, such as headless front ends or app-password integrations, now get content.rendered filtered per user, matching the front end. Editor flows (edit context) are unchanged.
  • Visitors can no longer use the dynamic routes on password-protected posts (until they enter the password) or on non-viewable post types.
  • WooCommerce category/tag/attribute conditions read global $product, which REST doesn't set. Their negated forms may now show a block in REST that the front end hides.

Closes https://github.com/Codeinwp/otter-internals/issues/316

Checklist before the final review

  • Included E2E or unit tests for the changes in this PR.
  • Visual elements are not affected by independent changes.
  • It is at least compatible with the minimum WordPress version.
  • It loads additional script in frontend only if it is required.
  • Does not impact the Core Web Vitals.
  • In case of deprecation, old blocks are safely migrated.
  • It is usable in Widgets and FSE.
  • Copy/Paste is working if the attributes are modified.
  • PR is following the best practices

@pirate-bot

pirate-bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Plugin build for ff9f009 is ready 🛎️!

@pirate-bot

Copy link
Copy Markdown
Contributor

Bundle Size Diff

Package Old Size New Size Diff
Animations 178.33 KB 178.33 KB 0 B (0.00%)
Blocks 1.65 MB 1.65 MB 0 B (0.00%)
CSS 7.83 KB 7.83 KB 0 B (0.00%)
Dashboard 172.49 KB 172.49 KB 0 B (0.00%)
Onboarding 68.14 KB 68.14 KB 0 B (0.00%)
Export Import 4.73 KB 4.73 KB 0 B (0.00%)
Pro 439.85 KB 439.85 KB 0 B (0.00%)

@pirate-bot

pirate-bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

E2E Tests

Playwright Test Status: See serial and parallel matrix jobs

Performance Results serverResponse: {"q25":461.59,"q50":500.07,"q75":512.24,"cnt":10}, firstPaint: {"q25":1507.28,"q50":1608.48,"q75":1952.8,"cnt":10}, domContentLoaded: {"q25":4318.39,"q50":4392.75,"q75":4427.34,"cnt":10}, loaded: {"q25":4321.72,"q50":4396.11,"q75":4430.6,"cnt":10}, firstContentfulPaint: {"q25":2841.4,"q50":3232,"q75":3295.68,"cnt":10}, firstBlock: {"q25":19761.29,"q50":19889.46,"q75":20073.76,"cnt":10}, type: {"q25":33.57,"q50":37.73,"q75":42.01,"cnt":10}, typeWithoutInspector: {"q25":27.38,"q50":32.19,"q75":37.45,"cnt":10}, typeWithTopToolbar: {"q25":44.55,"q50":49.84,"q75":53.51,"cnt":10}, typeContainer: {"q25":19.65,"q50":21.46,"q75":22.77,"cnt":10}, focus: {"q25":180.28,"q50":186.24,"q75":211.47,"cnt":10}, inserterOpen: {"q25":52.53,"q50":55.42,"q75":62.19,"cnt":10}, inserterSearch: {"q25":35.91,"q50":36.83,"q75":39.62,"cnt":10}, inserterHover: {"q25":5.75,"q50":6.4,"q75":7.45,"cnt":20}, loadPatterns: {"q25":2213.02,"q50":2257.43,"q75":2304.61,"cnt":10}, listViewOpen: {"q25":289.51,"q50":304.13,"q75":327.6,"cnt":10}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new CSS escapes are stripped during persistence and file writing, corrupting affected CSS values.

2 open findings
What changed in this PR

Hardens dynamic content, stored CSS, Atomic Wind styles, and conditional block rendering against XSS and unauthorized disclosure.

Changes:

  • Restricts dynamic-content matching and REST access.
  • Escapes inline CSS and Atomic Wind caches.
  • Enforces conditions in REST/admin-ajax contexts with regression tests.
File Description
tests/​test-woo-conditions.php Tests missing WooCommerce carts.
tests/​test-dynamic-content.php Tests token boundaries and URL escaping.
tests/​test-dynamic-content-server.php Tests dynamic REST permissions.
tests/​test-css-handler.php Tests stored CSS neutralization.
tests/​test-block-conditions-rest.php Tests REST and admin-ajax conditions.
tests/​test-atomic-wind-blocks.php Tests Atomic Wind CSS escaping.
plugins/​otter-pro/​inc/​plugins/​class-dynamic-content.php Handles invalid product IDs safely.
plugins/​otter-pro/​inc/​plugins/​class-block-conditions.php Handles unavailable carts safely.
inc/​server/​class-dynamic-content-server.php Tightens dynamic route authorization.
inc/​plugins/​class-dynamic-content.php Hardens token matching and URLs.
inc/​plugins/​class-block-conditions.php Applies conditions outside editor rendering.
inc/​plugins/​class-atomic-wind-blocks.php Escapes stored Atomic Wind CSS.
inc/​css/​class-css-handler.php Adds style-content escaping.
inc/​css/​class-block-frontend.php Escapes CSS before inline output.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread inc/css/class-css-handler.php Outdated
Comment thread inc/css/class-css-handler.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Valid magic tags inside attributes can still inject contributor-controlled values as live HTML attributes.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread inc/plugins/class-dynamic-content.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Attribute magic tags currently double-encode values already escaped by dynamic-content providers.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread inc/plugins/class-dynamic-content.php

@pirate-bot pirate-bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes requested

The new link and image token patterns cut meta keys at spaces, and three changed branches need tests.

Validation details
  • Files reviewed: 14/14 changed files. Two questions stayed unchecked: "consumer: Dynamic_Content_Server::check_permission() return changed" and "consumer: REST route '/dynamic': register_rest_route() call changed".
  • PHPUnit ran on PHP 8.3 with the WordPress 7.1.3 test library. The 22 new tests passed on HEAD and failed on the base tree.
  • A kses check on WordPress 7.1.3 kept the space in a saved link: mkdir -p /workspace/checks/F1; cd /var/www/html; wp eval '$h = "<a href=\"#otterDynamicLink?type=postMetaURL&amp;metaKey… printed HEAD token: #otterDynamicLink?type=postMetaURL&amp;metaKey=my (run R4).
  • Source checks found no unescaped inline CSS print, a balanced REST condition stack and guarded WooCommerce conditions.
  • Question for the author: The description says the escape runs after the decode in compress(). The code escapes at the print sites instead. Please update the description.
Untested areas
  • "consumer: Dynamic_Content_Server::check_permission() return changed" and "consumer: REST route '/dynamic': register_rest_route() call changed": open inc/plugins/class-dynamic-content.php:283. Check whether a Pro ACF repeater image with one item keeps the REST URL in src. If it does, visitors now get a 403 inside synced patterns and templates.
  • Editor and browser screens did not run. JavaScript dependencies and a Pro build were absent.
  • The base test run used a stub animation asset because the build folder is absent.

🤖 Automated review · run code-review-agent_6acf2ee8168ca2.61911910.


🤖 Review agent — review posted ✅ on 00d6a730 · changes requested · 5 findings · 40 min

Run code-review-agent_6acf2ee8168ca2.61911910 · trail

Comment thread inc/plugins/class-dynamic-content.php Outdated
Comment thread inc/plugins/class-dynamic-content.php Outdated
/**
* Resolve a text Magic Tag, or attribute-escape every Magic Tag inside a start tag.
*
* @param array<int|string, string> $matches Start tag in `tag`, or the Magic Tag query in group 2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types The pattern always yields keys 0, tag and 1. Key 2 exists only for a text Magic Tag. Declare that shape: @param array{0: string, tag: string, 1: string, 2?: string} $matches.

@pirate-bot pirate-bot Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open at 40e112d. The description now names key 2, but the type is still array<int|string, string>. The pattern always yields 0, tag and 1, and 2 only for a text Magic Tag. Declare @param array{0: string, tag: string, 1: string, 2?: string} $matches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied: this method is the callback for preg_replace_callback(), whose contract is callable(array<string>): string. Declaring array{0: string, tag: string, 1: string, 2?: string} narrows the callable's parameter below that contract. On 40e112d, PHPStan 2.2.0 then fails at line 130 with argument.type ("expects callable(array): string"). The type stays array<int|string, string>, with the keys documented in the description, and the code branches on isset( $matches[2] ).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceded. This method is a preg_replace_callback() callback, so the callback contract dictates the parameter type. A narrower shape would break that contract. The current array<int|string, string> type plus its description of the keys is enough here.

Comment thread inc/plugins/class-dynamic-content.php Outdated
Comment thread inc/plugins/class-dynamic-content.php
Comment thread inc/css/class-block-frontend.php

@pirate-bot pirate-bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes requested

Four earlier findings are fixed. The $matches type in this thread is still too broad.

Validation details
  • Files reviewed: 3/3 files changed since the prior review. Two questions stayed unchecked: "consumer: Dynamic_Content_Server::check_permission() return changed" and "consumer: REST route '/dynamic': register_rest_route() call changed".
  • PHPUnit 9.6 ran on PHP 8.3. The four new tests passed on HEAD: cd /repo; set -o pipefail; phpunit --do-not-cache-result --filter 'test_dynamic_tokens_keep_spaced_meta_keys|test_dynami… printed OK (4 tests, 12 assertions) (run R4)
  • Three of them failed on the base tree: cd /workspace/checks/AB/basetree; set -o pipefail; phpunit --do-not-cache-result --filter 'test_dynamic_tokens_keep_spac… printed Tests: 4, Assertions: 5, Failures: 3. (run R3)
  • The spaced-key test failed with the earlier file restored: cd /workspace/checks/AB/priortree; set -o pipefail; phpunit --do-not-cache-result --filter 'test_dynamic_tokens_keep_spa… printed Tests: 1, Assertions: 1, Failures: 1. (run R5)
Untested areas
  • "consumer: Dynamic_Content_Server::check_permission() return changed" and "consumer: REST route '/dynamic': register_rest_route() call changed": the Pro image producer was not confirmed.
  • Three Post Content tests errored because the build folder is absent.
  • Editor screens did not run.

🤖 Automated review · run code-review-agent_6acf40214df198.51803395.


🤖 Review agent — review posted ✅ on 40e112d7 · changes requested · 0 findings · 12 min

Run code-review-agent_6acf40214df198.51803395 · trail

@girishpanchal30
girishpanchal30 requested review from pirate-bot and removed request for pirate-bot October 14, 2026 09:04
@girishpanchal30
girishpanchal30 force-pushed the bugfix/otter-internals/316 branch from 40e112d to f3624fe Compare October 15, 2026 04:40

@pirate-bot pirate-bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes requested

The editor's Post Content preview now hides conditioned blocks, a Pro image path still fatals, and two REST stack cases need tests.

  • plugins/otter-pro/inc/plugins/class-dynamic-content.php:765 — correctness The PR says Pro no longer fatals when id is not a product. Only the REST path got the guard. The front-end evaluate_content_media_content() still calls $product->get_image_id() on the result of wc_get_product(). That result is false for a deleted product or a non-product ID. A post whose dynamic product image points to a deleted product then fatals on every view. A direct call returned Error: Call to a member function get_image_id() on false on HEAD. Apply the same guard: $image = $product ? $product->get_image_id() : 0;. Add a case next to test_product_image_ignores_non_product_id.
Validation details
  • Files reviewed: 14/14 changed files. Four questions stayed unchecked: the get_cached_css() return change, issue #2937, issue #2929 and issue #2696.
  • Changed PHPUnit tests ran on HEAD and on a base tree with PHP 8.3 and WordPress 7.1.3. All pass on HEAD. Twenty of 21 fail on the base tree.
  • A probe test fetched an editor's Post Content preview. HEAD returned only PUBLIC. Base returned SECRET and PUBLIC.
Untested areas
  • Atomic_Wind_Blocks::get_cached_css() return change: check that the callers at lines 336, 363 and 445 still get the CSS they expect.
  • Issue #2937, widget CSS writability: check that save_widgets_styles() still works without WP_Filesystem().
  • Issue #2929, post-loop warning: check that Magic Tags still resolve against the loop's context post.
  • Issue #2696, template part assets: check that get_post_css() still prints CSS for template parts.
  • Magic Tags inside a malformed start tag resolve without escaping. No producer of such markup was found after kses.

🤖 Automated review · run code-review-agent_6ad0596eca38c4.37487895.


🤖 Review agent — review posted ✅ on f3624fed · changes requested · 3 findings · 49 min

Run code-review-agent_6ad0596eca38c4.37487895 · trail

Comment thread inc/plugins/class-block-conditions.php Outdated
Comment thread tests/test-block-conditions-rest.php

@pirate-bot pirate-bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes requested

The earlier findings are fixed, but two comments are now stale and the new tests repeat cleanup code.

Validation details
  • Files reviewed: 4/14 changed files since the prior review. The rest reuse run code-review-agent_6ad0596eca38c4.37487895, not rerun. Unchecked: the get_cached_css() return change, issue #2929, issue #2696 and the FSE CSS print path.
  • Changed PHPUnit tests pass on HEAD with PHP 8.3: cd /repo; grep -n "Requires PHP" readme.txt otter-blocks.php | head -3; mkdir -p /workspace/checks/T1; set -o pipefail; … printed OK (10 tests, 26 assertions) (run R1).
  • The f3624fe Pro file fails the new test: D=/workspace/checks/T1/tree; P=plugins/otter-pro/inc/plugins/class-dynamic-content.php; cd /repo; git show f3624fed302f3… printed Call to a member function get_image_id() on false (run R5).
Untested areas
  • get_cached_css() return change: callers at lines 336, 363 and 445 were not rechecked.
  • Issue #2929: Magic Tags against the loop's context post were not rechecked.
  • Issue #2696: template part CSS printing was not rechecked.
  • FSE CSS print path: enqueue_fse_css() prints CSS without escape_style_contents(). Template edits need edit_theme_options.

🤖 Automated review · run code-review-agent_6ad0678f05afa8.84083586.


🤖 Review agent — review posted ✅ on ec3a08a5 · changes requested · 3 findings · 28 min

Run code-review-agent_6ad0678f05afa8.84083586 · trail

Comment thread inc/plugins/class-block-conditions.php Outdated
Comment thread tests/test-block-conditions-rest.php Outdated
Comment thread tests/test-block-conditions-rest.php Outdated

@pirate-bot pirate-bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The PR passes. Commit ff9f009 fixes all three earlier findings, and the checks found no new problem.

Validation details
  • Files reviewed: 2/14 changed files since the prior review. The other files reuse run code-review-agent_6ad0678f05afa8.84083586, not rerun.
  • The REST condition tests pass on HEAD with PHP 8.3: cd /repo; set -o pipefail; timeout 300 vendor/bin/phpunit --do-not-cache-result --filter 'Test_Block_Conditions_Rest::' … printed OK (5 tests, 14 assertions) (run R1).
  • The same tests fail on the base code: cd /repo; B=/workspace/checks/T1/base; rm -rf $B; mkdir -p $B; set -o pipefail; rsync -a --exclude node_modules --exclud… printed Tests: 5, Assertions: 11, Failures: 4. (run R2).
  • A focused check covered cached Atomic Wind CSS, Magic Tags in a loop and template part CSS: cd /repo; sed -n 285,296p inc/class-blocks-css.php; python3 - <<'EOF' p='/workspace/checks/T2/Test_Review_Rows.php' s=op… printed OK (3 tests, 18 assertions) (run R6). Loop and template part output match the base code in run R7.
Untested areas
  • Full Site Editing and Customizer CSS printing were not rechecked. That code is unchanged.

🤖 Automated review · run code-review-agent_6ad0728f60be71.41682677.


🤖 Review agent — review posted ✅ on ff9f0098 · approved · 0 findings · 11 min

Run code-review-agent_6ad0728f60be71.41682677 · trail

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants