# Patch Analysis — CVE-2026-54823 (Widget Options 4.2.4 fix)

## Scope & sources analysed

- Vulnerable: Widget Options **4.2.3** (`bundle/artifacts/wo-versions/ext-4.2.3`).
- Fixed: Widget Options **4.2.4** (`bundle/artifacts/wo-versions/ext-4.2.4`).
- Latest/current: Widget Options **4.2.5** (`bundle/artifacts/wo-versions/ext-4.2.5`).
- Key files: `includes/extras.php`, `includes/widgets/gutenberg/gutenberg-toolbar.php`,
  `includes/widgets/display.php`, `includes/snippets/class-snippets-cpt.php`,
  `includes/snippets/class-snippets-api.php`, `includes/pagebuilders/{elementor,beaver,siteorigin}*.php`.
- **4.2.4 == 4.2.5 on the security surface:** `cmp` reports
  `extras.php` and `gutenberg-toolbar.php` byte-identical; 4.2.5 `readme.txt`
  changelog is only "Updated and tested for WordPress version 7.0". No further
  security changes between fixed and latest.
- No upstream git commit hash was published with the advisory; the fix ships in
  plugin version 4.2.4 (`readme.txt`: "Strengthened security for Authenticated
  (Contributor+) Remote Code Execution vulnerability").

## Target threat model

WordPress security model: an authenticated **Contributor** (role with
`edit_posts` but not `manage_options`/`publish_posts`) is a low-privilege
trusted-by-authentication user who must **not** obtain code execution on the
server. The plugin's "Display Logic" feature evaluates PHP expressions via
`eval()`. The trust boundary is: REST-API/HTTP request body (attacker-controlled
block attributes) -> server-side `eval()`. The fix's job is to ensure
attacker-controlled expressions never reach `eval()` for non-administrators.

## What the fix changes (files / functions / logic)

### 1. `includes/extras.php` — closed-default trust gate (G1, PRIMARY)
New functions and a rewritten `widgetopts_safe_eval()`:
- `widgetopts_eval_trust_begin()` / `widgetopts_eval_trust_end()` — increment/decrement
  a global `_widgetopts_trust_depth` counter.
- `widgetopts_eval_is_trusted()` — `!empty($GLOBALS['_widgetopts_trust_depth'])`.
- `widgetopts_safe_eval_trusted($expression)` — wraps `widgetopts_safe_eval()`
  inside `trust_begin`/`trust_end` (try/finally), so the expression is evaluated
  *as trusted*.
- `widgetopts_safe_eval($expression)` — **inverted default**:
  ```php
  if (!current_user_can('manage_options') && !widgetopts_eval_is_trusted()) {
      return true;   // <-- closed default: contributor never evals
  }
  if (widgetopts_is_widget_or_post_preview()) {
      if (!current_user_can('manage_options')) { return true; } // G5 preview
  }
  ... validate ... @eval($expression);
  ```
  In 4.2.3 the only non-admin guard was the **preview-context** short-circuit
  (`widgetopts_is_widget_or_post_preview()` checks `$_GET['context']==='edit'`,
  `is_preview()`, customizer, builder edit modes). On any non-preview render the
  old code eval'd for everyone. 4.2.4 makes "no eval" the default for non-admins
  unless the trusted wrapper vouches for the input.

### 2. `includes/extras.php` — token validator hardening (G2)
`widgetopts_validate_code_with_tokens()`:
- `$code = stripslashes($code);` then `token_get_all`.
- Forbidden constructs list now includes `T_FUNCTION` and (if defined) `T_FN`
  (blocks closure define + immediate invoke), in addition to `T_EVAL`,
  `T_INCLUDE`, `T_INCLUDE_ONCE`, `T_REQUIRE`, `T_REQUIRE_ONCE`, `T_EXIT`, `T_GOTO`.
- The "token before `(`" dynamic-call detector now also treats **`}`** as a
  blocking delimiter (in addition to `]` and `)`), defeating `${'z'}(...)`
  variable-variable calls and `Foo::{'m'}(...)`. It also blocks
  `T_VARIABLE`/`T_CONSTANT_ENCAPSED_STRING` before `(` (`$fn()`, `'f'()`),
  method/static/nullsafe operators before an identifier, and
  `T_ENCAPSED_AND_WHITESPACE` outright.

### 3. `includes/widgets/gutenberg/gutenberg-toolbar.php` — block-renderer allowlist (G3)
- `rest_pre_dispatch` filter (priority 1): if the route starts with
  `/wp/v2/block-renderer/`, set `$GLOBALS['_widgetopts_in_block_renderer']=true`;
  `rest_post_dispatch` unsets it. Scopes the allowlist to that single route.
- `render_block_data` filter (priority 5): for REST + block-renderer + non-admin,
  if a block's `attrs.extended_widget_opts[_block].class.logic` is non-empty,
  compute `sha256(logic)` and look it up in
  `widgetopts_get_post_logic_allowlist($post_id)` (the set of sha256 of logic
  values actually stored in the referenced post's `post_content`, parsed via
  `parse_blocks`). Mismatch -> zero the logic before `render_callback`.
  `$post_id` comes from `get_post()->ID` or `absint($_REQUEST['post_id'])`.

### 4. `gutenberg-toolbar.php` + `extras.php` — save/REST stripper (G4)
- `wp_insert_post_data` filter (priority 10, 2 args): for non-admins, before
  `wp_insert_post`'s `wp_unslash`, parse `post_content`, run
  `widgetopts_strip_logic_from_blocks()` (which zeroes
  `extended_widget_opts[_block].class.logic` in parsed attrs **and** rewrites
  freeform `<!--start_widgetopts ... end_widgetopts-->` comments via a
  permissive regex + `json_decode` with trailing-garbage recovery, clearing
  `class.logic`), re-slash, and write back. Fires on **all** saves (classic +
  block editor + programmatic `wp_insert_post`).
- `rest_request_before_callbacks` filter (`extras.php`): for non-admin
  **write** requests (POST/PUT/PATCH/DELETE), recursively scrubs
  `extended_widget_opts[_block].class.logic`, `widgetopts_logic`,
  `widgetopts_settings_logic`, and block/freeform strings via
  `widgetopts_rest_scrub_legacy_logic()` (which pre-screens for
  `extended_widget_opts`/`start_widgetopts` substrings and re-parses+strips).
  GET/HEAD/OPTIONS are untouched (covered by G3 on the block-renderer route).

### 5. `widgetopts_is_widget_or_post_preview()` (G5)
Unchanged behaviour but now sits *after* G1; for a non-admin in a preview
context it returns `true` (no eval) even when the trusted wrapper set the flag,
preventing a contributor from previewing their own draft to fire logic.

## Assumptions the fix makes

1. **A1 — Trust flag is only set for admin-controlled input.** Every
   `widgetopts_safe_eval_trusted()` call site feeds it DB-backed/admin-authored
   expressions, so letting non-admin *viewers* execute them is safe. Audited
   call sites:
   - `includes/widgets/display.php:586` — classic widget instance options
     (`widget_<id>` option, `edit_theme_options` to edit).
   - `includes/widgets/gutenberg/gutenberg-toolbar.php:1034` — block
     `extended_widget_opts.class.logic` (legacy inline) from parsed block attrs.
   - `includes/pagebuilders/elementor/render.php:391`,
     `beaver/beaver.php:930`, `siteorigin.php:55` — page-builder element
     settings (builder edit access = admin/editor).
   - `includes/snippets/class-snippets-api.php:153,246` — `widgetopts_snippet`
     CPT code. The CPT is admin-only: `class-snippets-cpt.php` sets
     `capability_type=>'post'` with `capabilities` overriding
     `edit_post/edit_posts/create_posts/delete_post/publish_posts` to
     `manage_options` (lines 76-86). Admin AJAX endpoints all check
     `manage_options`/`WIDGETOPTS_MIGRATION_PERMISSIONS`.
   => A Contributor can neither set the trust flag nor supply input to any
   trusted call site. **Assumption holds.**
2. **A2 — Contributors cannot persist malicious logic.** G4 strips
   `class.logic` (parsed attrs + freeform comments) on every non-admin save and
   REST write. A Contributor can only create **drafts** (cannot publish), and
   previewing is blocked by G5. **Assumption holds.**
3. **A3 — block-renderer is the only no-save server render path.** G3 scopes
   the sha256 allowlist to `/wp/v2/block-renderer/`. Other REST render routes
   that might render user blocks without a save step would not be covered by
   G3 — but they would still be covered by **G1** (contributor -> no eval
   regardless). **Assumption holds because G1 is input-agnostic.**
4. **A4 — sha256 allowlist is sound.** Bypassing G3 requires a sha256 preimage
   of an admin's stored logic (infeasible) or reusing the admin's exact string
   (admin-controlled). Empty/invalid `post_id` -> empty allowlist -> all logic
   zeroed. **Assumption holds.**

## Code paths / inputs the fix does NOT cover (and why each is non-exploitable)

- **Freeform `<!--start_widgetopts-->` render extractor** sets the trust flag
  (calls `widgetopts_safe_eval_trusted` in `blockopts_filter_before_display`)
  and G3's allowlist inspects only parsed `attrs`, not `innerContent`. **But:**
  G4 strips freeform comments on save; the block-renderer endpoint does not
  populate `innerContent` (it builds blocks from request `attributes`), so the
  extractor cannot fire through that endpoint; and saved drafts only render on
  preview (G5) or after admin publish. => Not exploitable by a contributor.
- **Validator-only bypasses (stripslashes differential, alternate dynamic-call
  shapes).** Moot on 4.2.4+ because G1 blocks a contributor before the
  validator is ever consulted (verified by the parse-error probe returning
  `true` = eval never reached).
- **`post_id` manipulation against G3.** Infeasible preimage / empty-allowlist
  zeroing (see A4).
- **GET to block-renderer (scrub is write-only).** Covered by G3 (allowlist
  fires on `render_block_data` regardless of method) and G1.

## Behaviour before vs after the fix

| Aspect | 4.2.3 (before) | 4.2.4/4.2.5 (after) |
|--------|----------------|---------------------|
| Non-admin reaches `eval()` on non-preview render | **Yes** (only preview guarded) | **No** (G1 closed default) |
| Contributor payload via block-renderer REST | RCE | Blocked at G1 (and G3 allowlist) |
| `${'z'}(...)` brace-call token | Accepted (`}` not blocked) | Rejected by G2 (`}` before `(`) |
| Freeform `<!--start_widgetopts-->` logic on save | stripped (G4 present from earlier partial patch) | stripped (G4) |
| Trust flag setter | n/a (no trust concept) | `widgetopts_safe_eval_trusted()` only; all call sites admin-controlled |
| Snippet CPT authoring | admin-only | admin-only (`manage_options`) |

## Completeness assessment

**The fix is complete for the Contributor -> `eval()` trust boundary.** G1
(input-agnostic closed default) is the authoritative control and removes the
trust-boundary crossing entirely for non-administrators; G2-G5 are defense in
depth. No materially distinct contributor-reachable path to `eval()` was found
on 4.2.4 or 4.2.5. The bounded variant search (5 runtime candidates + 4
capability-audited paths) is documented in `rca_report.md`; the negative result
is backed by the token-extracted harness evidence in
`bundle/logs/vuln_variant/`.
