Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
276 lines
25 KiB
Markdown
276 lines
25 KiB
Markdown
# 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=<that 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:<draft 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<Template> $templates
|
||
* @return array{templates: list<array{id: int, title: string, use_on: list<string>}>, hidden_templates: int}
|
||
*/
|
||
private function templateDetails(array $templates): array
|
||
{
|
||
$shown = array_values(array_filter(
|
||
$templates,
|
||
fn (Template $t): bool => $this->access->canEditTemplate($t->id)
|
||
));
|
||
|
||
return ['templates' => self::listing($shown), 'hidden_templates' => count($templates) - count($shown)];
|
||
}
|
||
```
|
||
- `DiviReplaceTextAbility::refused()` (`:402-408`): add `'hidden_templates' => $data['hidden_templates'] ?? 0`, and keep `isset($data['templates'])` as the discriminator. It stays set, possibly `[]`.
|
||
- `DiviWriteAbility::outputSchema()` (`DiviWriteAbility.php:45`): add `'hidden_templates' => ['type' => 'integer']`.
|
||
- Optionally append `Template::hiddenOverlapsNote`-style wording to the message: "… davon %d ohne Berechtigung".
|
||
- Tests:
|
||
- A layout shown only by an uneditable live template: still `confirm_required`, `templates: []`, `hidden_templates: 1`.
|
||
- The confirmed save still succeeds.
|
||
- The replace-text refusal carries no uneditable ID.
|
||
|
||
### L3 – Low (rollback): TB layout (and library entry) revisions cannot be restored through MCP, while the docs and results present the revision as the way back
|
||
|
||
**Where**
|
||
- `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php:152-162` and `RevisionsListAbility.php:104-111`: `PostGuard::editable()` with `ContentSchema`. `ContentSchema::EXCLUDED` (`ContentSchema.php:19-22`) contains `et_header_layout`, `et_body_layout`, `et_footer_layout`, `et_template` and `et_pb_layout`, so both tools answer "nicht gefunden".
|
||
- `docs/guides/theme-builder.md:17` and `:100-101` ("Layouts erhalten wie Divi-Seiten eine Revision").
|
||
- The write result carries `revision_id` (`DiviWriter::save()`).
|
||
- Live: `wp_revisions_enabled()` is true for 1061/1062/1063/2391, so the revision exists. It is reachable only through `wp-admin/revision.php?revision=<id>` (no UI link, because the TB types have `show_ui` off in the admin menu).
|
||
|
||
**Scenario**
|
||
1. A confirmed edit changes the live site header (dry run, confirm and revision as designed, Q2/Q9).
|
||
2. The user asks the assistant to undo it.
|
||
3. The assistant, following the guide, calls `lit/revisions-list` / `lit/revision-restore` for the layout and gets "nicht gefunden".
|
||
4. No MCP path remains to return the visible site to its previous state. The same gap exists for global library entries (3a; not recorded there).
|
||
5. The restore point exists (the backup guarantee holds). Its use through the tool the plugin itself points to fails, so a bad visible change stays visible longer than the design assumes.
|
||
|
||
**Evidence:** by reading, plus the live `wp_revisions_enabled` probe.
|
||
|
||
**Fix** (either one):
|
||
- **Minimal:** add `admin_url('revision.php?revision=' . $revisionId)` as a `restore_url` to the result of a TB layout and library write (`DiviWriter::savePrepared()`, and `save()` for these scopes). Fix both guide passages: "Revision im WordPress-Revisionsvergleich wiederherstellen (restore_url); lit/revision-restore gilt nur für Seiten und Beiträge".
|
||
- **Complete (Etappe U):** let `lit/revision-restore` accept revisions of TB layouts and library entries through `DiviPageLoader::load(…, true)` (ThemeBuilderAccess/LibraryAccess rights and group gates). Treat the restore as a visible edit: route it through `ThemeBuilderEdits::prepare()` / `GlobalEdits::prepare()` for the dry run, confirm and P8a.
|
||
|
||
### L4 – Low (integrity): `lit/tb-assign reattach` puts any published template on the live list that the live list does not name, including templates owned by a Divi Theme Builder library post
|
||
|
||
**Where**
|
||
- `src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php:134` and `:148-181`: "detached" means only "not in the live list" (`Template::$attached`, `ThemeBuilderAccess::checkedTemplate()` `:329`).
|
||
- `DiviThemeBuilder::detachedIds()` (`DiviThemeBuilder.php:147-167`), the source tb-list shows as `detached`, excludes templates listed by *any* `et_theme_builder` post: live, draft, and library TB posts marked `_et_library_theme_builder` (Divi `local-library.php:407-427`).
|
||
|
||
**Scenario**
|
||
1. A TB user (full TB rights) calls `lit/tb-assign` with `reattach: true` and the ID of a template that belongs to a Theme Builder library item. The ID is not shown by tb-list, but it is an `et_template` they can `edit_post`.
|
||
2. The template is attached to the live list (disabled). Later tb-assign/tb-activate calls change the library item's own conditions and enabled flag, because it is the same post.
|
||
3. Nothing becomes visible without the confirmed tb-activate, and the account could do the same in Divi's UI. So this is data integrity, not escalation.
|
||
4. It contradicts the tool's own contract: "only templates a later Theme Builder save removed" (B19).
|
||
|
||
**Evidence:** by reading. DDEV has no library TB post (`et_theme_builder` 1064 only).
|
||
|
||
**Fix:** in `reattachRefusal()`, when `$reattach` is set, refuse unless no `et_theme_builder` post lists the template. Give the same answer as a not-found (P12 is not needed; the user has TB rights, but reuse the message "Vorlage %d ist keine entfernte Vorlage (siehe detached in lit/tb-list)"). Add `DiviThemeBuilder::isListedAnywhere(int $templateId): bool` (one `SELECT 1 FROM postmeta WHERE meta_key = '_et_template' AND meta_value = %s LIMIT 1`), which is the criterion `detachedIds()` uses. Test: a template listed only by a second `et_theme_builder` post is refused for reattach.
|
||
|
||
---
|
||
|
||
## Info / hardening
|
||
|
||
### I1 – `{{field:<key>}}` in a Theme Builder body publishes the field of every post the template covers, including fields hidden for the author of the change
|
||
|
||
- `DynamicContentRules::fieldsFor()` (`DynamicContentRules.php:102-111`) accepts every non-protected `custom_meta_<key>`. It checks neither the Divi role option `read_dynamic_content_custom_fields` (Divi counts it "dangerous": `core.php:1138`) nor the field's `FieldDefinition::HIDDEN` status for the user.
|
||
- `DynamicFields::offered()` (`DynamicFields.php:54`) skips hidden fields. So `ThemeBuilderEdits::fieldWarnings()` reports a hidden field as "kein bekanntes Feld dieses Inhaltstyps" (`:90`) and saves anyway (E12, warnings only).
|
||
- Once the template is live, visitors see that field's value for every post of the type.
|
||
- Preflight ruled display as parity: Divi gates only the edit context, and Divi's manual meta-key entry (`DynamicContentOptionPostMetaKey.php:307`, `! $is_manual_entry`) works without the option.
|
||
- TB rights imply `edit_theme_options` (normally administrators), so this is recorded, not a finding.
|
||
- Cheap hardening: in `ThemeBuilderEdits::checkKeys()` and `TbCreateTemplateAbility` (body), refuse a key that is `HIDDEN` for the user in any post type of the template's conditions, instead of warning. That extends the "hidden fields are neither offered nor named" rule (2b M4) to "nor published".
|
||
|
||
### I2 – Disabling the `lit/tb-*` tools one by one does not close Theme Builder layouts to the Divi tools
|
||
|
||
- `Plugin.php:100-105` wires `groupEnabled()`/`groupWritable()` (`ExposurePolicy.php:185-197`), which read only the group mode and the "nur lesen" switch, not per-tool overrides.
|
||
- An administrator who turns off all four TB tools individually (instead of the group) still lets `lit-divi-update` & co. edit live TB layouts (with dry run and confirm).
|
||
- This is the same model as the library group (only `lit/library-insert` has a cross-tool gate, audit 3a L5).
|
||
- Hardening: make the gate `groupEnabled(GROUP) && allows('lit/tb-list', …)`, as done for `libraryInsertable`. Or state in the Tools tab that the group switch, not the tool switches, governs TB layouts.
|
||
|
||
### I3 – Confirm of live TB changes is not bound to the dry run
|
||
|
||
- `ThemeBuilderEdits::prepare()`, `TbAssignAbility::dryRun()` and `TbActivateAbility::dryRun()` recompute everything at confirm (rights, `editableLiveTemplates()`, `template_rev`/`tree_rev`). So rights are safe.
|
||
- The set of templates or overlaps the user agreed to can still grow between the dry run and the confirm. This is the same class as 3a I2 and the deferred tree_rev race.
|
||
- Optional: accept an `expected_templates` hash.
|
||
|
||
---
|
||
|
||
## Deferred items re-checked (no change of severity)
|
||
|
||
- **Q13 replace-text prefilter/next_cursor oracle:** the prefilter uses `PostGuard::postType(…, $schema)`, i.e. managed types only (`DiviReplaceTextAbility.php:133`). TB and library types are never searched, so it gives no new TB oracle. Explicit TB targets go through ThemeBuilderAccess. Stays U.
|
||
- **Attach race** (`DiviThemeBuilder::attach()` vs a concurrent Divi TB save): at worst the new template is dropped from the list and marked unused. It is disabled, so nothing becomes visible. Stays U.
|
||
- **try/catch around Divi read calls:** a throw in `et_theme_builder_get_template()` ends the request before any write. Every TB write is a small meta sequence with explicit undo. Stays U.
|
||
- **tree_rev race:** unchanged class, and TB layouts inherit it. Stays U.
|
||
- **DNS pinning, preset gates:** not reachable from the TB code. Stay U and 4.
|
||
|
||
---
|
||
|
||
## Areas checked and found sound
|
||
|
||
- **Rights on every new write path:**
|
||
|
||
| Path | Checks |
|
||
|---|---|
|
||
| `lit/tb-create-template` | `canCreate()` = `canUse()` (`edit_others_posts`, `edit_theme_options`, Divi `theme_builder`), group gate, `publish_posts` of all four types, draft refusal. `permission()` and `execute()` both run it. |
|
||
| `lit/tb-assign` / `lit/tb-activate` | `changeableTemplate()` = `template()` (published `et_template`, `canEditTemplate` = `visible()` + `edit_post`, else not found – Q18), group gate, draft refusal (except the off-switch – Q20), not default, `template_rev`. |
|
||
| Off-switch for trashed live templates (Q16) | Raw lookup requires live list, enabled and `canEditTemplate`. Every other unpublished ID reads as missing. |
|
||
| TB layouts in the Divi tools | `DiviPageLoader::load()` → `ThemeBuilderAccess::layout()`: not visible → `PostGuard::notFound` (the same text as a missing ID or an unmanaged type); `edit_post`; for writes the group gate and `publish`. TB types stay out of `ContentSchema`, so generic content, status, meta, revision and duplicate tools cannot reach them. |
|
||
| `lit/library-make-global` | Sources and targets only `page`/`post` (Q24/Q25). Library right `add_library`, plus `canSave(true)` for a new entry. Every target through `DiviWriter::page()` (`edit_post`, `tree_rev`) and `StoredNodeGuard`. Live targets get the VisibleEdits backup refusal before any write. |
|
||
| `lit/library-find-similar` | `canList()`, `edit_post` per scanned post, TB and library types never scanned, own-posts restriction. The reference entry goes through `LibraryAccess::item()`. |
|
||
|
||
- **kses window:**
|
||
- TB layout content reaches `DiviSaver` only through `DiviWriter::commit()` → `commitLayout()`: `ThemeBuilderEdits` first, then `save()`. The alternative is `saveNew()` after `LayoutBuilder` (Composer + TreeEditor, the D7 choke point) in tb-create-template.
|
||
- Titles go through `sanitize_text_field` and core kses, since Divi's `insert_layout`/`store_template` run outside the window.
|
||
- Conditions are regex-limited (`^[A-Za-z0-9_:\-]{1,200}$`) and taken only from Divi's flat list.
|
||
- Multisite site admins (TB rights, no `unfiltered_html`) get the guard's strict branch. The backup is refused (P9) rather than made lossy.
|
||
- **Area rule (F22):** `StructureRules::areaViolation()` and `nestedAreaViolation()` run in `TreeEditor::insert()` for composed and stored nodes (copy, library insert, duplicate) alike.
|
||
- **"Live" semantics:** `Template::isLive()` (attached and enabled, any status) and `showsLayout()` (area enabled) match `ThemeBuilderRequest::get_template()`: enabled only; default template only if enabled. Checked live: the default template 1065 has `enabled: false`, so its header 1061 is not live, and the plugin agrees. `showing()` decides confirm on all live templates. With L2's fix it must keep doing so.
|
||
- **Rollback:**
|
||
- `TemplateBuild` records layouts, a Divi-created live TB post (B12) and the template, including posts inserted before a Divi failure (`InsertedBeforeFailure`, Q19). It undoes detach first and deletes newest first with `layout_type` terms. Partial results name only the leftover IDs.
|
||
- tb-assign restores conditions, detach and enabled. tb-activate restores the flag.
|
||
- `DiviSaver::restore()` writes back the previous content.
|
||
- VisibleEdits backups are drafts moved to the trash. Divi's `et_theme_builder_trash_draft_and_unused_posts()` queries default (non-trash) statuses and never touches them.
|
||
- **wp_die guard:** the right is checked before Divi; all six `wp_die_*` handler filters are replaced by a throwing closure, removed in `finally` together with the output buffers.
|
||
- **Conditions (TemplateConditions):**
|
||
- Posts need the exact type, a living status and `read_post`.
|
||
- Terms need the right taxonomy and `assign_term`.
|
||
- Users need `list_users`. Roles must exist.
|
||
- Other object types are refused.
|
||
- Missing and unreadable objects get the same message, and labels name objects by stored ID only.
|
||
- Divi's flat list holds 34 IDs without post titles (live).
|
||
- **Oracles:**
|
||
- Every TB ID (layout or template) the user may not use answers like a missing one.
|
||
- tb-list shows uneditable templates only as the `hidden_live` count, and layout titles only with `edit_post`.
|
||
- `shared_with` lists only visible templates.
|
||
- tb-create's `global` source gives the same answer for a missing and an uneditable default layout.
|
||
- `{{link:}}`/`post_link_url_*` refuse TB and library types uniformly (Q14: not in `et_builder_get_public_post_types()`).
|
||
- Remaining gaps: L1 (library dry run) and L2.
|
||
- **Dynamic content and Pods:**
|
||
- `post_link_url_<type>` requires the target's exact type (P10, `PostIdParser` without saturation).
|
||
- `DynamicFields::metaKeyOf()` only reads (both Divi forms, Q11); writing stays on `VariableRef::canonical()` (S29/S30).
|
||
- Pods table storage is detected with a `try`.
|
||
- Hidden fields are never named. Field warnings name only editable templates and count the rest.
|
||
- **make-global identity (Q25):** `Fingerprint::canonical()` includes text parts and opaque bytes. The instance is `GlobalInstance::of(<entry root>)`. `UsageMatcher::referencesAny()` fails closed on any `"globalModule"` key and on D4 `global_module` shortcodes.
|
||
- **Disclosure elsewhere:** outline, node-get, text search and render summary hide library IDs without library access (P13). SimilarSections only warns while `revealsIds()` holds, and only with entries the user may `edit_post`.
|
||
- **DoS:**
|
||
- find-similar is capped (200 posts, 200 matches, 5000 nodes, chunks of 50).
|
||
- make-global allows at most 20 targets × 20 IDs.
|
||
- Conditions are capped at 50.
|
||
- `detachedIds` is limited to 50.
|
||
- The TB write clears all static CSS, which the same account can do in Divi anyway.
|
||
|
||
## Status nach der Fix-Welle (2026-09-27)
|
||
- L1 (Q17 M1): behoben in 43dc3fb – GlobalUsages nutzt den Theme-Builder-Zugriff; ohne Sicht nur Zählung, weiterhin blockierend.
|
||
- L2 (Q22): behoben in 7590da4 – nur bearbeitbare Live-Vorlagen werden genannt, andere als hidden_templates; confirm entscheidet weiter über alle.
|
||
- L3: minimal behoben in dcae2d4 und 771f379 – restore_url zum WordPress-Revisionsbildschirm, auch im Fehlerfall; Wiederherstellen über MCP folgt in Etappe U.
|
||
- L4: behoben in e64d32b – reattach nur für Vorlagen, die kein Theme-Builder-Beitrag listet.
|
||
- I1, I3: bewusst belassen (siehe Rulings); I2: in der Theme-Builder-Anleitung dokumentiert (50cfa42).
|
||
- Scoped Re-Review: Approved; 2202 Tests grün, tb- und divi-library-Smoke grün.
|