Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
25 KiB
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_HTMLoff,et_pb_role_settingsempty (Divi defaults),EMPTY_TRASH_DAYS30. Live Theme Builder post 1064 lists templates 1065 (default), 1066 and 2394, all withenabled: 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.phpet_pb_is_allowed,DynamicContentOptionsController, andDynamicContentOptionPostMetaKey. - Ran four read-only
wp eval-fileprobes. 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_enabledon 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 statusis clean.vendor/bin/phpunit: 2173 tests, OK.
- Read every new or changed
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 whenroles->allows('theme_builder')andedit_postboth hold. It does not checkedit_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 withpost_id,title,statusandtypewheneverread_postholds. For the TB layout types (capability typepost, published,map_meta_cap)read_postis plainread. Checked in-memory: authorread_post(1061) = true.- Wiring:
src/Integrations/Divi/DiviIntegration.php:197(GlobalEdits) andsrc/Integrations/Divi/Library/LibraryIntegration.php:64(LibraryListAbility) both buildnew GlobalUsages(null, $roles)without the Theme Builder access. - Compare
ThemeBuilderAccess::canUse()(ThemeBuilderAccess.php:51-61), andlayout()/visible()(:63-67,:87-98). Every direct TB path answers a TB ID like a missing one without these rights (P12).
Scenario
- The site owner enables the Divi role option
theme_builderfor editors in Divi's role editor. Editors still lackedit_theme_options, so they cannot open Divi's Theme Builder page, and everylit/tb-*tool and every Divi tool on a TB layout refuses them (Q15). - A global module sits in the Theme Builder header of a live template.
- The editor has
edit_global_libraryandedit_poston the entry. They edit the entry with any Divi tool and confirm. - P8a passes the header row (
theme_builderon,edit_postviaedit_others_posts). The site-wide header changes, which Q15 says this account may not do. - The same holds when the group "Divi Theme Builder" is set to "nur lesen" or disabled for the connection.
- Disclosure, any account with library rights (authors too): the dry run's
affectedlists the TB layout's ID, title and type.blocked_idsnames it by ID. For the same account,lit-divi-outline post_id=<that ID>answers "nicht gefunden". - 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:98with$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 throughDiviIntegration::library()→LibraryIntegration.php:64):new GlobalUsages(null, $this->access->roles(), fn (): bool => $this->themeBuilderAccess->visible() && $this->themeBuilderAccess->writableGate() === null)visible()already containscanUse(). ThewritableGate()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, usecanUse() === nullforeditable()andvisible()for the listing. - Tests:
- A TB row with
theme_builderon but withoutedit_theme_optionsis 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.
- A TB row with
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_onwith object IDs).- Passed on unchanged by
src/Integrations/Divi/Pages/DiviReplaceTextAbility.php:402-408(templatesin the per-page refusal). - Compare
ThemeBuilderAccess::editableLiveTemplates()(ThemeBuilderAccess.php:193-201). tb-list, tb-assign, tb-activate andfieldWarnings()(ThemeBuilderEdits.php:117-160) already use it (Q21).
Scenario
- A TB user may edit layout L (
edit_poston 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". - Any Divi write on L without
confirmanswersconfirm_requiredwithtemplates: [{id: T, title, use_on: [...]}]. - That discloses T's ID, title and conditions, including object IDs such as
singular:post_type:page:id:<draft ID>. - In practice this is narrow: the account already holds
edit_others_postsand the TB rights, soedit_poston 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 keepisset($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.
- A layout shown only by an uneditable live template: still
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-162andRevisionsListAbility.php:104-111:PostGuard::editable()withContentSchema.ContentSchema::EXCLUDED(ContentSchema.php:19-22) containset_header_layout,et_body_layout,et_footer_layout,et_templateandet_pb_layout, so both tools answer "nicht gefunden".docs/guides/theme-builder.md:17and: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 throughwp-admin/revision.php?revision=<id>(no UI link, because the TB types haveshow_uioff in the admin menu).
Scenario
- A confirmed edit changes the live site header (dry run, confirm and revision as designed, Q2/Q9).
- The user asks the assistant to undo it.
- The assistant, following the guide, calls
lit/revisions-list/lit/revision-restorefor the layout and gets "nicht gefunden". - 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).
- 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 arestore_urlto the result of a TB layout and library write (DiviWriter::savePrepared(), andsave()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-restoreaccept revisions of TB layouts and library entries throughDiviPageLoader::load(…, true)(ThemeBuilderAccess/LibraryAccess rights and group gates). Treat the restore as a visible edit: route it throughThemeBuilderEdits::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:134and: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 asdetached, excludes templates listed by anyet_theme_builderpost: live, draft, and library TB posts marked_et_library_theme_builder(Divilocal-library.php:407-427).
Scenario
- A TB user (full TB rights) calls
lit/tb-assignwithreattach: trueand the ID of a template that belongs to a Theme Builder library item. The ID is not shown by tb-list, but it is anet_templatethey canedit_post. - 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.
- 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.
- 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-protectedcustom_meta_<key>. It checks neither the Divi role optionread_dynamic_content_custom_fields(Divi counts it "dangerous":core.php:1138) nor the field'sFieldDefinition::HIDDENstatus for the user.DynamicFields::offered()(DynamicFields.php:54) skips hidden fields. SoThemeBuilderEdits::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()andTbCreateTemplateAbility(body), refuse a key that isHIDDENfor 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-105wiresgroupEnabled()/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-inserthas a cross-tool gate, audit 3a L5). - Hardening: make the gate
groupEnabled(GROUP) && allows('lit/tb-list', …), as done forlibraryInsertable. 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()andTbActivateAbility::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_templateshash.
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-templatecanCreate()=canUse()(edit_others_posts,edit_theme_options, Divitheme_builder), group gate,publish_postsof all four types, draft refusal.permission()andexecute()both run it.lit/tb-assign/lit/tb-activatechangeableTemplate()=template()(publishedet_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 andpublish. TB types stay out ofContentSchema, so generic content, status, meta, revision and duplicate tools cannot reach them.lit/library-make-globalSources and targets only page/post(Q24/Q25). Library rightadd_library, pluscanSave(true)for a new entry. Every target throughDiviWriter::page()(edit_post,tree_rev) andStoredNodeGuard. Live targets get the VisibleEdits backup refusal before any write.lit/library-find-similarcanList(),edit_postper scanned post, TB and library types never scanned, own-posts restriction. The reference entry goes throughLibraryAccess::item(). -
kses window:
- TB layout content reaches
DiviSaveronly throughDiviWriter::commit()→commitLayout():ThemeBuilderEditsfirst, thensave(). The alternative issaveNew()afterLayoutBuilder(Composer + TreeEditor, the D7 choke point) in tb-create-template. - Titles go through
sanitize_text_fieldand core kses, since Divi'sinsert_layout/store_templaterun 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.
- TB layout content reaches
-
Area rule (F22):
StructureRules::areaViolation()andnestedAreaViolation()run inTreeEditor::insert()for composed and stored nodes (copy, library insert, duplicate) alike. -
"Live" semantics:
Template::isLive()(attached and enabled, any status) andshowsLayout()(area enabled) matchThemeBuilderRequest::get_template(): enabled only; default template only if enabled. Checked live: the default template 1065 hasenabled: 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:
TemplateBuildrecords 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 withlayout_typeterms. 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 infinallytogether 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).
- Posts need the exact type, a living status and
-
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_livecount, and layout titles only withedit_post. shared_withlists only visible templates.- tb-create's
globalsource gives the same answer for a missing and an uneditable default layout. {{link:}}/post_link_url_*refuse TB and library types uniformly (Q14: not inet_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,PostIdParserwithout saturation).DynamicFields::metaKeyOf()only reads (both Divi forms, Q11); writing stays onVariableRef::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 isGlobalInstance::of(<entry root>).UsageMatcher::referencesAny()fails closed on any"globalModule"key and on D4global_moduleshortcodes. -
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 mayedit_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.
detachedIdsis 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
dcae2d4und771f379– 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.