Files
WP-LIT-MCP/docs/superpowers/notes/2026-09-27-etappe-3b-security-audit.md

25 KiB
Raw Permalink Blame History

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:
    // 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):
    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):

// 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.