docs: Etappe 3b rulings and security audit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Etappe 3b – Entscheidungen (Rulings), Befunde und offene Punkte
|
||||
|
||||
Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3b-security-audit.md. Abnahme: 2026-09-27-etappe-3b-abnahme.md.
|
||||
|
||||
## Offen für spätere Etappen
|
||||
- Etappe U: Revisionen von Theme-Builder-Layouts und Bibliothekseinträgen über MCP wiederherstellen (jetzt nur restore_url zum WordPress-Revisionsbildschirm, Audit L3); Oracle über Vorfilter/next_cursor von replace-text (Q13); Race beim Anhängen von Vorlagen; try/catch um Divi-Aufrufe; tree_rev-Race; DNS-Pinning; Build-Hygiene.
|
||||
- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren.
|
||||
- Bewusst belassen: {{field:}} im Body-Layout warnt nur (Anzeige-Parität mit Divi, Audit I1); Bestätigung ist nicht an den Probelauf gebunden (wie 3a, Audit I3); gemeinsamer confirm-Helfer verworfen (Q17 M4).
|
||||
|
||||
## Ledger
|
||||
|
||||
Spec: docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md; decisions: docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (§5 user answers binding, F1: TB templates disabled by default + explicit activation tool); rulings 1a/1b/2a/2b/3a in docs/superpowers/notes/.
|
||||
Branch: etappe-3 (base 0edb474; 3a merged to main 4f05388), in place. Pods 3.3.9.2 activated locally by user permission (2026-09-27), no pods defined yet.
|
||||
Process rules carried over: opus reviewers for security-relevant tasks; controller verifies live with benign probes if reviewer agents stop; smoke guard; commits by explicit path; final review + security audit + one fix wave (breaker after round 2).
|
||||
|
||||
## Pre-flight rulings
|
||||
Pre-flight (opus): 1 Critical, 6 Important, 16 Minor, 7 Info; all anchors match once; full tables in preflight.md (B1–B23).
|
||||
- Ruling Q1 (B1, Critical): 3b fixtures use their own pod `lit_fixture_person` („[Fixture] Personen“) that no smoke deletes; wp-basis-smoke's shared lit_person stays as is — cost if wrong: none
|
||||
- Ruling Q2 (B2): extract GlobalEdits' backup + confirm-merge into a shared helper; live TB layout edits reuse it (backup when revisions off, P9 refusals) — cost if wrong: none
|
||||
- Ruling Q3 (B3): lit/tb-assign leaves omitted exclude_from unchanged; dry run shows both lists (before/after) — cost if wrong: none
|
||||
- Ruling Q4 (B4): TemplateBuildTest uses assertPartials — cost if wrong: none
|
||||
- Ruling Q5 (B5, USER-APPROVED 2026-09-27): Task 1 Step 8 may enable the fixture template briefly, conditions limited to exactly one „[Fixture]“ person, disabled again in the same step (try/finally), verified off afterwards; Step 6 does not change Divi's post-type setting — cost if wrong: momentary visible change on one fixture URL
|
||||
- Ruling Q6 (B6): tb-smoke creates/deletes its own temporary pod, never touches lit_person or lit_fixture_person — cost if wrong: none
|
||||
- Ruling Q7 (B7): UsageMatcher::referencesAny() refuses any global reference in every spelling Divi renders; used by lit/library-make-global and lit/library-save scope global (no self-reference, no nested refs in shortcode text) — cost if wrong: none
|
||||
- Ruling Q8 (B8–B21 minors): adopt as proposed in preflight.md (template live = attached+enabled regardless of post status; layouts must be published before reattach/activate; tb-list hides IDs of templates the user can't edit; test/count/doc fixes; smoke library-id check via jq) — cost if wrong: small
|
||||
- Ruling Q9 (B22 / plan open point): live-visible TB edits use dry run + confirm (with Q2 backup), not refusal — matches F2 — cost if wrong: none
|
||||
- Ruling Q10 (B23 / plan open point): author conditions require list_users — only admins hold Divi's theme_builder right by default — cost if wrong: editors with theme_builder can't set author conditions
|
||||
|
||||
## Progress
|
||||
- Task 1: dispatched (opus, BASE 0edb474; Q1 own pod, Q5 user-approved brief activation)
|
||||
- Task 1: done (c615522; fixtures: pod lit_fixture_person 2381, persons 2385/2386, body layout 2391, template 2394 disabled, global header 1061; brief activation only for Erika, off again; live site unchanged). Finding 10: stale TB editor save detaches our template silently + marks unused (7-day trash) → confirms plan mitigation (detached/reattach). Finding 12: Divi dialog writes Pods fields as post_meta_key + select_meta_key "custom_meta_<key>" (empty meta_key); plugin writes custom_meta_<key>; choice shows stored value; relation shows raw ID
|
||||
- Ruling Q11: Task 11 (Pods field check) and any usage/field detection recognize both forms (custom_meta_<key> and post_meta_key/select_meta_key); S29/S30 meta-key rules already cover both on write — cost if wrong: missed warnings
|
||||
- Ruling Q12: Task 1 (fixtures/test data only) verified by controller from report + commit instead of a separate review; fixtures are re-checked by later task reviews — cost if wrong: none
|
||||
- Task 1: complete (0edb474..c615522)
|
||||
- Tasks 2–3: dispatched (opus, BASE c615522, one commit each)
|
||||
- Tasks 2–3: implementer done (06f1379 P10 PostIdParser + type-exact post_link_url; c7ce924 P13 revealsIds + replace-text skipped_nodes ID-oracle fix; 1923 tests); concern: replace-text prefilter/next_cursor partial oracle → U (Ruling Q13)
|
||||
- Tasks 2–3: review (opus): both Approved, live-verified (P10 nothing widened; P13 no ID in any path, digit oracle fixed, nur lesen reveals). Important (pre-existing): {{link:<library ID>}} type oracle ("Unbekannter Name post_link_url_et_pb_layout" vs "gibt es nicht")
|
||||
- Ruling Q14: WpLinkTargets treats et_pb_layout (and any post type without a Divi post_link_url option, incl. internal _-types) as not found → uniform message; minors: merge stacked docblocks WordPressIntegration.php:52-56, DiviTextSearch withoutLibraryIds doc (hides more for undecodable opaque openers) — fix round 1 now — cost if wrong: none
|
||||
- Ruling Q13 (confirmed): replace-text prefilter/next_cursor oracle deferred to U, documented as parity with WordPress' own content search
|
||||
- Tasks 2–3: fix round 1 (Q14; 82ef69a; 1926 tests): WpLinkTargets linkable = Divi's et_builder_get_public_post_types() minus attachment/_-types/et_pb_layout; identical answers library vs missing ID (test); DiviTextSearch strips IDs only in global nodes — verified by controller against report/tests
|
||||
- Tasks 2, 3: complete (c615522..82ef69a)
|
||||
- Task 4: dispatched (opus, BASE 82ef69a; wp_die guard, ThemeBuilderAccess, Q8 minors)
|
||||
- Task 4: implementer done (d04a508; 1958 tests); helpers for T7/T9/T10: ThemeBuilderAccess::canEditTemplate(), layoutsRefusal(); attach race → U
|
||||
- Task 4: review (opus): Approved; wp_die guard live-verified (CLI/AJAX/JSON, nested, no output/status leak, handlers restored, nothing written); ThemeBuilderAccess = Divi's save gate; P12 no oracle; isLive matches Divi
|
||||
- Ruling Q15: ThemeBuilderAccess::canUse additionally requires edit_theme_options (Divi's TB admin page gate) — never laxer than Divi's UI — cost if wrong: roles with theme_builder but without edit_theme_options can't use TB tools
|
||||
- Ruling Q16 (T4 minors): T10 must be able to deactivate an attached+enabled template that sits in the trash (raw lookup for the off-switch, fail-safe); docblock note on third-party wp_die inside hooks; canEditTemplate docblock (IDs from Divi's list); dedupe GlobalUsages::THEME_BUILDER_TYPES/DiviSaver cache clear/compat row → final wave
|
||||
- Task 4: fix round 1 (Q15 + docblocks) dispatched
|
||||
- Task 4: fix round 1 (Q15; 4ff1c1e; 1959 tests) — verified by controller
|
||||
- Task 4: complete (82ef69a..4ff1c1e)
|
||||
- Task 5: dispatched (opus, BASE 4ff1c1e; Q2 shared backup/confirm helper, Q9, Q8)
|
||||
- Task 5: implementer done (b1dd35a; 1980 tests; smoke ok); VisibleEdits shared helper (GlobalEdits/ThemeBuilderEdits/DiviWriter); TB layouts get Divi flags (as Divi writes); T11 adds ?DynamicFields after VisibleEdits param
|
||||
- Task 5: review (opus): Approved; live: 9 write paths on live layout → confirm_required/merged confirm/replace-text refused, confirmed save with revision, backup when revisions off; access P12 no oracle; post-content body-only on all paths; GlobalEdits byte-identical after Q2
|
||||
- Ruling Q17 (T5 minors): M1 GlobalUsages P8a TB rows reuse ThemeBuilderAccess::canUse (not only theme_builder) → final wave (security, must); M2 confirm description mentions live TB layouts → T16; M3 library-path P9/P14 wording test → final wave; M4 optional VisibleEdits::confirmed → final wave
|
||||
- Task 5: complete (4ff1c1e..b1dd35a)
|
||||
- Tasks 6–7: dispatched (opus, BASE b1dd35a, one commit each; Q10, Q8, group wiring)
|
||||
- Tasks 6–7: implementer done (ac69fdf TemplateConditions: 34 Divi IDs, 27 round-trip live, assign_term for terms, list_users for authors, uniform missing/unreadable; 54922a9 tb-list + group live: nur lesen blocks TB writes, disabled → not_found; 1998 tests)
|
||||
- Tasks 6–7: review (opus): both Approved; live: 34 Divi condition IDs covered by construction, parsing safe, uniform missing/unreadable, tb-list leaks nothing, detached shown, group matrix verified, never enabled a template
|
||||
- Ruling Q18: ThemeBuilderAccess::template() answers not_found (identical to missing) for templates the user can't edit (canEditTemplate), consistent with tb-list (Q8/B11) — implement in T8 (first caller) incl. test; minors → final wave: DETACHED_LIMIT applied after edit filter + truncation note, invalid id message, role-slug regex doc
|
||||
- Tasks 6, 7: complete (b1dd35a..54922a9)
|
||||
- Task 8: dispatched (opus, BASE 54922a9; F1, Q4, Q8, Q18, rollback reuse)
|
||||
- Task 8: implementer done (42b9b38; 2020 tests; live create: disabled+attached, tb-list shows, fixture page byte-identical, cleanup complete); controller deleted 3 leftover change-log rows of the live check (1248–1250)
|
||||
- Task 8: review (opus): never-enabled verified on all paths incl. forced read-back; rollback per step live; unsaved-editor detection = Divi's hasDraft; rights live. Important: post inserted by Divi before it fails (throw inside store_template/insert_layout after wp_insert_post) not tracked → orphan published et_template (disabled/detached, no visible effect); minors: orphan layout_type term relationships after delete, partial() names deleted IDs, refuse() loses details, default template editability
|
||||
- Ruling Q19: while guarded() runs, record IDs of posts inserted of the expected types (wp_insert_post action) and hand them over (exception subclass carrying IDs) so TemplateBuild rolls them back; undo() also removes layout_type term relationships; partial() lists only IDs whose delete failed; refuse() keeps all failure details; defaultTemplate() respects canEditTemplate — fix round 1 — cost if wrong: none
|
||||
- Task 8: fix round 1 (Q19; 2827fd4; 2030 tests; live wp_die-in-hook on template and layout save → write_failed, nothing left) — verified by controller; residual (throw between DB insert and first cache clean) documented, accepted
|
||||
- Task 8: complete (54922a9..2827fd4)
|
||||
- Tasks 9–10: dispatched (opus, BASE 2827fd4, one commit each; Q3, Q9, Q8, Q16, Q18)
|
||||
- Tasks 9–10: implementer done (0975b2f tb-assign; 64fc439 tb-activate; 2056 tests; live on temp draft only, visible site unchanged, cleanup verified)
|
||||
- Ruling Q20: the off-switch (tb-activate enabled:false) is exempt from the unsaved-editor refusal (E7) — reducing visibility must always be possible; result warns that saving an open Theme Builder editor may restore its own state — cost if wrong: a stale editor may re-enable (warned)
|
||||
- Tasks 9–10: review (opus): spec ✅ both; live: no path makes a template visible except confirmed activate (reattach disables first), Q3/Q9 dry runs, Q16 trash off-switch no oracle, rights, change log; visible site unchanged at every step; changes requested: overlap lists leak uneditable live templates (Q8/B11), Q20 not yet implemented
|
||||
- Ruling Q21: one shared ThemeBuilderAccess::editableLiveTemplates() used by tb-list, tb-assign and tb-activate dry runs; uneditable live templates only as a count („weitere aktive Vorlagen ohne Berechtigung“); Q20 implemented (off-switch skips draft refusal + warning); minors: dedupe overlaps vs possible_overlaps, reattach records previous enabled state in previous/log, stricter exclude_from null handling, live from $after->isLive() — fix round 1
|
||||
- Tasks 9–10: first fix-round implementer stopped by user mid-way; user said 'weiter' → fresh opus implementer continues from uncommitted partial work (Q20, Q21, minors)
|
||||
- Tasks 9–10: fix round 1 (Q20, Q21, minors; a31d434; 2068 tests); tb-list hidden_live count; re-review running
|
||||
- Tasks 9–10: re-review clean (Q21 no leak, counts only; Q20 live with a real Divi draft; minors fixed)
|
||||
- Ruling Q22 (must, final wave): ThemeBuilderEdits::showing()/listing() use editableLiveTemplates() (count for hidden) — same leak class as Q21, in layout-edit confirm details — cost if wrong: none
|
||||
- Tasks 9, 10: complete (2827fd4..a31d434)
|
||||
- Task 11: dispatched (opus, BASE a31d434; Q11 both forms, Q21 pattern)
|
||||
- Task 11: implementer done (6be2e65; 2084 tests; live read-only on 2391); DynamicFields::metaKeyOf reads both forms (Q11), choice/relation notes, editable templates only, dry run shows field warnings
|
||||
- Ruling Q23: the plan's review checkpoint after Task 11 is covered by per-task reviews + the final whole-branch review (every task so far reviewed with live checks) — no separate checkpoint review — cost if wrong: late discovery of cross-task drift (final review)
|
||||
- Task 11: review (sonnet): Approved; metaKeyOf matches Divi's DynamicContentOptionPostMetaKey branch for branch, live fixture 2391 unchanged, no leak, named-arg wiring correct
|
||||
- Task 11: complete (a31d434..6be2e65)
|
||||
- Tasks 12–13: dispatched (opus, BASE 6be2e65, one commit each; bounded, no leaks, cheap write-time warning)
|
||||
- Tasks 12–13: implementer done (f82964f find-similar: caps 200 posts/200 matches/5000 nodes, batch loads; 4f70886 write-time warning: ≤200 library sections cached per request; 2102 tests; smoke ok)
|
||||
- Tasks 12–13: review (sonnet): both Approved; live find-similar as admin + temp author: no oracle (P12 forbidden for foreign reference), pagination exact, excluded types uniform; minors → final wave: excluded-type message names "" type; multi-type own-posts restriction documented
|
||||
- Tasks 12, 13: complete (6be2e65..4f70886)
|
||||
- Tasks 14–15: dispatched (opus, BASE 4f70886, one commit each; Q7 referencesAny, reuse library-save/GlobalInstance/VisibleEdits, per-page results)
|
||||
- Tasks 14–15: implementer done (44c1804 make-global + NewLibraryEntry shared + referencesAny; e733065 replace identical occurrences: pre-check all targets, per-page commit+revision, stop at first failure with changed/failed/unchanged lists; 2160 tests; smokes ok)
|
||||
- Ruling Q24: make-global refuses Theme Builder layouts also as SOURCE (stricter than brief; keeps TB edits on the TB tools) — cost if wrong: a TB section can't be made global directly (copy to a page first)
|
||||
- Tasks 14–15: review (opus, SEC): T14 Approved (referencesAny 27 spellings live, rights, rollback); T15 Important: Fingerprint::body ignores text parts / core-block inner HTML → "identical" target with different visible content replaced (live, silent content swap); minors: revision note on page-N failure, "pages and posts only" wording vs Divi-enabled types; Low: Divi re-runs do_blocks in shortcode module/Woo checkout text → escaped global-layout inside JSON value expands (document with P25)
|
||||
- Ruling Q25: Fingerprint content hash includes non-whitespace text parts and full bytes of opaque blocks (structure hash unchanged); plans() additionally compares normalized serialized bytes of the node; regression tests for both variants; find-similar "identical" uses the same fix; failed-page message keeps "Revision X bleibt erhalten"; scope check matches wording (only page/post types, or wording = Divi-enabled types) — decide pages+posts only; Low item documented with P25 — fix round 1 — cost if wrong: none
|
||||
- Tasks 14–15: fix round 1 (Q25; 4e95983; 2170 tests; smokes ok; live: altered copies no longer identical, forced confirm refused, pages unchanged) — verified by controller against the reviewer's repro scenario
|
||||
- Tasks 14, 15: complete (4f70886..4e95983)
|
||||
- Tasks 16 (docs, opus) and 17 (tb-smoke + acceptance, opus) dispatched in parallel, BASE 4e95983 (disjoint files)
|
||||
- Task 16: implementer done (3db96a3; 2173 tests; v0.5.0); docs honest about open Q22/Q17-M1 (final wave)
|
||||
- Task 16: review approved (spec ✅, quality approved; 2173 tests re-run green; only cosmetic line-wrap nit)
|
||||
Task 16: complete
|
||||
- Task 17: implementer done (0b7a1c2; 2173 tests; all 7 smokes green); deviations: enabled template on temp draft page via preview, temp pod never enabled, read-only → not found. Review dispatched (sonnet)
|
||||
- Task 17: review approved (spec ✅, quality approved; smoke re-run live green, no leftovers, TB baseline 1065=0 1066=0 2394=0)
|
||||
Ruling: Claude-Session trailer after Co-Authored-By is correct — the session's current attribution instruction requires both lines; common.md predates it — cost if wrong: cosmetic
|
||||
Task 17: complete
|
||||
- Final whole-branch review + security audit dispatched in parallel (opus), range 0edb474..0b7a1c2
|
||||
- Final review done: 0 critical, 3 important (I-1 Q22, I-2 Q17M1 + TB-ID leak to library-only editors, I-3 Q17M3), 12 minor; report final-review.md. Ruling: drop Q17 M4 (shared confirm helper) — 13 call sites project-wide, out of scope — cost: minor duplication stays. Waiting for security audit before fix wave.
|
||||
- Security audit done: 0 C/H/M, 4 Low (L1=Q17M1, L2=Q22, L3 TB/library revisions not restorable via MCP, L4 reattach accepts TB-library templates), 3 Info; report security-audit.md
|
||||
Ruling: fix wave covers I-1..I-3, L1..L4, all final-review minors except Q17 M4 — both reports agree, all small — cost if wrong: extra churn
|
||||
Ruling: L3 gets the minimal fix (restore_url to WP revision screen + corrected docs), not MCP restore for TB/library types — the full route needs new access wiring, belongs to Etappe U — cost: restore stays a manual step in wp-admin
|
||||
Ruling: I1 stays a warning (display parity, preflight ruling), I2 documented in guide, I3 unchanged like 3a — cost: none beyond 3a status quo
|
||||
- Final fix wave dispatched (opus), BASE 0b7a1c2
|
||||
- Fix wave done: 7590da4 43dc3fb 79f2fdb dcae2d4 e64d32b 5290a9d e7bbe1e 14bc954 6288acf 50cfa42 beb8706; 2199 tests, lint clean, tb/library/write smokes green, TB baseline off
|
||||
Ruling: accept stricter I-2 (read-only TB group also blocks global edits reaching TB layouts) — matches the guide and group semantics — cost: a global edit may be blocked where the owner expected it to pass; one-line revert noted in fixwave-report
|
||||
Ruling: accept M-9 forbidden for accounts without any TB rights (tool-level, ID-independent, same as tb-list) — no existence oracle since answer does not depend on the ID; Q18 not_found stays for per-template refusals — cost: answer-code change for clients
|
||||
- Scoped re-review dispatched (opus), range 0b7a1c2..beb8706
|
||||
- Re-review: Approved (all in-scope findings addressed; N-1 minor docblock, N-2/N-3 info). Ruling: fix N-1, partial-write restore_url (completes L3) and make-global backup hint now via resumed fix-wave agent; N-2 as guide sentence; N-3 left — all tiny, closes L3 cleanly — cost: one more small commit set
|
||||
@@ -0,0 +1,275 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user