# Etappe 3b (Divi Theme Builder): security audit - **Scope:** branch etappe-3, `0edb474..0b7a1c2` (HEAD). New: `src/Integrations/Divi/ThemeBuilder/*`; library find-similar and make-global (`Library/LibraryFindSimilarAbility`, `LibraryMakeGlobalAbility`, `NewLibraryEntry`, `SimilarSections`, `UsageMatcher::referencesAny`); `Pages/VisibleEdits`, `Pages/DynamicFields`, `Pages/LayoutBuilder`, `BlockTree/Fingerprint`, `Compose/PostIdParser`, `WpLinkTargets`, and the loader, writer, saver, exposure and wiring changes (`DiviPageLoader`, `DiviWriter`, `SaveScope`, `DiviIntegration`, `Plugin`). - **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1, Pods 3.3.9.2), single site, only user `admin` (no editor or author accounts), `DISALLOW_UNFILTERED_HTML` off, `et_pb_role_settings` empty (Divi defaults), `EMPTY_TRASH_DAYS` 30. Live Theme Builder post 1064 lists templates 1065 (default), 1066 and 2394, all with `enabled: false`, and there is no Theme Builder draft. - **Method:** - Read every new or changed `src/` file in the range and traced each write path to its checks. - Read the Divi code the plugin mirrors or relies on: `theme-builder.php` (`et_theme_builder_get_template`, `_get_template_layouts`, `_trash_draft_and_unused_posts`, `store_template`), `ThemeBuilderRequest::get_template`, `local-library.php`, `core.php` `et_pb_is_allowed`, `DynamicContentOptionsController`, and `DynamicContentOptionPostMetaKey`. - Ran four read-only `wp eval-file` probes. The probe files were temporary files in the web container's `/tmp`, deleted afterwards. They covered the live template state, the post type caps of the TB types, `wp_revisions_enabled` on the TB layouts, and Divi's flat condition list. - Simulated the editor and author roles with an in-memory `WP_User` (ID 987654, never saved), because the site has no such accounts. - Nothing was written to the site. `git status` is clean. `vendor/bin/phpunit`: 2173 tests, OK. **Result:** no Critical, High or Medium findings. - The known items hold as recorded: Q22 and Q17 M1 are confirmed, both Low, with exact fixes below. - The deferred items (Q13 prefilter oracle, attach race, try/catch around Divi calls, DNS pinning, tree_rev race, preset gates) are not more severe than recorded. None of them reaches Theme Builder objects through a new path; see "Deferred items re-checked". - Findings: four Low (two of them Q17 M1 and Q22) and three Info notes. --- ## Findings (most severe first) ### L1 – Low (authorization, disclosure): P8a treats Theme Builder rows with Divi's `theme_builder` option alone, and the dry run names Theme Builder layouts to accounts that ThemeBuilderAccess hides them from (ruling Q17 M1, confirmed) **Where** - `src/Integrations/Divi/Library/GlobalUsages.php:218-225` (`editable()`): a TB row counts as editable when `roles->allows('theme_builder')` and `edit_post` both hold. It does not check `edit_others_posts`, `edit_theme_options` (ruling Q15), or the group "Divi Theme Builder" (disabled or "nur lesen"). - `GlobalUsages.php:98-106` (`report()`): a row is listed with `post_id`, `title`, `status` and `type` whenever `read_post` holds. For the TB layout types (capability type `post`, published, `map_meta_cap`) `read_post` is plain `read`. Checked in-memory: author `read_post(1061) = true`. - Wiring: `src/Integrations/Divi/DiviIntegration.php:197` (GlobalEdits) and `src/Integrations/Divi/Library/LibraryIntegration.php:64` (LibraryListAbility) both build `new GlobalUsages(null, $roles)` without the Theme Builder access. - Compare `ThemeBuilderAccess::canUse()` (`ThemeBuilderAccess.php:51-61`), and `layout()`/`visible()` (`:63-67`, `:87-98`). Every direct TB path answers a TB ID like a missing one without these rights (P12). **Scenario** 1. The site owner enables the Divi role option `theme_builder` for editors in Divi's role editor. Editors still lack `edit_theme_options`, so they cannot open Divi's Theme Builder page, and every `lit/tb-*` tool and every Divi tool on a TB layout refuses them (Q15). 2. A global module sits in the Theme Builder header of a live template. 3. The editor has `edit_global_library` and `edit_post` on the entry. They edit the entry with any Divi tool and confirm. 4. P8a passes the header row (`theme_builder` on, `edit_post` via `edit_others_posts`). The site-wide header changes, which Q15 says this account may not do. 5. The same holds when the group "Divi Theme Builder" is set to "nur lesen" or disabled for the connection. 6. Disclosure, any account with library rights (authors too): the dry run's `affected` lists the TB layout's ID, title and type. `blocked_ids` names it by ID. For the same account, `lit-divi-outline post_id=` answers "nicht gefunden". 7. Mitigation: Divi's own Visual Builder allows the same global edit from any page (parity with Divi, as audit 3a L6). The impact is the plugin's own stricter rule P8a/Q15, not an escalation beyond Divi. **Evidence:** by reading. Post type caps and `read_post` for editor and author were checked live, in-memory. The DDEV data has no global instance inside a TB layout, so no end-to-end probe was run. **Fix** - Give GlobalUsages the Theme Builder decision as a closure, so the Library namespace keeps no dependency on ThemeBuilder: ```php // GlobalUsages /** @param (Closure(): bool)|null $themeBuilder Whether TB objects are open to this account (ThemeBuilderAccess). */ public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null, private ?Closure $themeBuilder = null) … private function themeBuilderOpen(): bool { return $this->themeBuilder !== null ? ($this->themeBuilder)() : $this->roles->allows('theme_builder') // fail-safe default = ThemeBuilderAccess::canUse() && current_user_can('edit_others_posts') && current_user_can('edit_theme_options'); } private function isThemeBuilder(array $row): bool { return in_array($row['type'], self::THEME_BUILDER_TYPES, true); } private function editable(array $row): bool { if ($this->isThemeBuilder($row) && ! $this->themeBuilderOpen()) { return false; } return current_user_can('edit_post', $row['id']); } ``` - In `report()`, replace `:98` with `$readable = (! $this->isThemeBuilder($row) || $this->themeBuilderOpen()) && current_user_can('read_post', $id);`. TB rows the account cannot use then only count: `hidden_count`, `blocked_hidden`. They are still blocked, never named (P12, as ThemeBuilderAccess). - Wiring, at both call sites (`DiviIntegration.php:197`, and through `DiviIntegration::library()` → `LibraryIntegration.php:64`): ```php new GlobalUsages(null, $this->access->roles(), fn (): bool => $this->themeBuilderAccess->visible() && $this->themeBuilderAccess->writableGate() === null) ``` `visible()` already contains `canUse()`. The `writableGate()` part makes "nur lesen"/disabled for the TB group also block global edits that would change a live TB layout. If the controller wants only the Q17 M1 scope, use `canUse() === null` for `editable()` and `visible()` for the listing. - Tests: - A TB row with `theme_builder` on but without `edit_theme_options` is blocked (hidden count, no ID). - A TB row with the group "nur lesen" is blocked. - An author sees no TB row in `affected`. - A page row is unchanged. ### L2 – Low (disclosure): the confirm details of a live TB layout list every live template, including templates the user may not edit (ruling Q22, confirmed) **Where** - `src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php:43-52` (`showing()`: all live templates that show the layout). - `:59-62` (`prepare()`: `details['templates'] = self::listing($templates)`). - `:96-108` (`notice()`: the same for lit-divi-delete's own confirm). - `:274-285` (`listing()`: `id`, `title`, `use_on` with object IDs). - Passed on unchanged by `src/Integrations/Divi/Pages/DiviReplaceTextAbility.php:402-408` (`templates` in the per-page refusal). - Compare `ThemeBuilderAccess::editableLiveTemplates()` (`ThemeBuilderAccess.php:193-201`). tb-list, tb-assign, tb-activate and `fieldWarnings()` (`ThemeBuilderEdits.php:117-160`) already use it (Q21). **Scenario** 1. A TB user may edit layout L (`edit_post` on L) but not template T. T is hidden from them by Q8/B11/Q18: tb-list omits it, and tb-assign/tb-activate answer "gibt es nicht". 2. Any Divi write on L without `confirm` answers `confirm_required` with `templates: [{id: T, title, use_on: [...]}]`. 3. That discloses T's ID, title and conditions, including object IDs such as `singular:post_type:page:id:`. 4. In practice this is narrow: the account already holds `edit_others_posts` and the TB rights, so `edit_post` on a published template fails only under a capability plugin that restricts it (per-post or per-type rules). The class of leak is the one Q21 closed elsewhere. **Evidence:** by reading (the code paths above). No second account exists on DDEV. **Fix** (the decision stays on *all* live templates; only the answer is filtered): ```php // ThemeBuilderEdits public function prepare(DiviPage $page, array $input): array|WP_Error { $templates = $this->showing($page); // all live ones – decides whether confirm is needed $details = $this->templateDetails($templates); if ($templates === []) { … unchanged … } … message keeps self::count($templates) (the total) … } public function notice(DiviPage $page, WP_Error $error): WP_Error { … VisibleEdits::merged($error, …self::count($templates)…, $this->templateDetails($templates)) … } /** * Ruling Q21/Q22: live templates the user may edit by id/title/use_on, the others only as a count. * @param list