docs: Etappe 3a rulings and security audit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
# Etappe 3a – Entscheidungen (Rulings), Befunde und offene Punkte
|
||||||
|
|
||||||
|
Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3a-security-audit.md.
|
||||||
|
|
||||||
|
## Offen für spätere Etappen
|
||||||
|
- Etappe 3b: vollständiges Ausblenden von Bibliotheks-IDs bei deaktivierter Gruppe (P13); gleiche Zielregeln für rohe post_link_url_* wie für {{link:}} (P10); Theme Builder, find-similar, make-global.
|
||||||
|
- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren.
|
||||||
|
- Etappe U: try/catch um Divi-Aufrufe (P11), einheitliche Fehlercodes der Tools-Tab-Sperren, doppelte Helfer, Test-Feinschliff, „blurb ist leer“-Warnung, Rollen-Option in divi-write-smoke.
|
||||||
|
- Restrisiko (Low, P25): sehr ungewöhnliche Schreibweisen globaler Verweise auf Seiten, die der Benutzer nicht bearbeiten darf, können der Verwendungssuche entgehen (setzt Bearbeitungsrechte an der verweisenden Seite voraus).
|
||||||
|
|
||||||
|
## Pre-flight rulings
|
||||||
|
Pre-flight (opus): 0 Critical, 8 Important, 19 Minor, 4 Info; fixtures match byte for byte; {{link:ID}} to draft/private shows only ?page_id=. Full tables: preflight.md.
|
||||||
|
- Ruling P1 (I-1, → Task 9): lit/divi-replace-text on library entries goes through DiviWriter::commit() (dry run/confirm for global, site-wide cache clear, no Divi flags on library posts) or refuses library targets — implementer picks the variant that reuses commit(); F2 binding — cost if wrong: none
|
||||||
|
- Ruling P2 (I-2, → Task 11): rebuild the 3 test rows with Divi's \u0022 escaping and assert the parsed block is not opaque — cost if wrong: none
|
||||||
|
- Ruling P3 (I-3, → Task 11): StoredNodeGuard refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, opaque blocks and rich-text HTML that is not kses-stable when moving stored nodes between posts — cost if wrong: editors copy less (visible refusal)
|
||||||
|
- Ruling P4 (I-4, → Task 2): only the link row uses URL mode in the new placeholder test — cost if wrong: none
|
||||||
|
- Ruling P5 (I-5, → Task 13): lit-library-insert reads the entry via LibraryAccess (edit_post) — cost if wrong: none
|
||||||
|
- Ruling P6 (I-6, → Task 12): lit/library-save reuses NewDraft (partial_write + recordPartial on failed rollback) — cost if wrong: none
|
||||||
|
- Ruling P7 (I-7, → Task 12): scope "global" additionally requires edit_global_library (Divi's rule, F12) — cost if wrong: none
|
||||||
|
- Ruling P8 (I-8, → Task 9): a confirmed global edit is refused when the user cannot edit_post one of the published pages that render the entry (stricter than Divi; security first) — cost if wrong: editors with partial rights can't change shared modules
|
||||||
|
- Ruling P9 (minors): confirm stays honour-based (D10); a disabled library group hides library IDs from the Divi read tools; lit/divi-node-copy follows lit/divi-insert exposure (E9); refuse the backup path when a user without unfiltered_html edits with revisions off or trash disabled (kses backup loses bytes); output schema gains affected, affected_count, backup_id; unknown dynamic field types are omitted (F4); dispatch notes per preflight (OutlineTest anchor ×3, >120-char lines, dead variable) — cost if wrong: small
|
||||||
|
- Note: common.md fixed — PostChangeResult was renamed to PostSaveFollowUp in 2b W3
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- Task 1: dispatched (sonnet, BASE cfc81dd)
|
||||||
|
- Task 1: complete (404f0f3, trailer normalized; 2-line config change verified by controller; 1597 tests)
|
||||||
|
- Task 2: dispatched (opus, BASE 404f0f3; P4, link read_post check)
|
||||||
|
- Task 2: implementer done (788943e; 1609 tests); note T16 docs: attachments refused by {{link:}}
|
||||||
|
- Task 2: review (opus): Approved; live: read_post enforced (private/foreign draft/trash refused, uniform message), ID forms refused, URL-only on all paths, no leak
|
||||||
|
- Ruling P10 (T2 minors → final wave): full-form post_link_url_* uses LinkTargets (same target rules as {{link:}}; trashed/unattached attachment consistent) + one shared ID parser; attachments refused by {{link:}} documented in T16 — cost if wrong: none
|
||||||
|
- Task 2: complete (404f0f3..788943e)
|
||||||
|
- Tasks 3–5: dispatched as one batch (sonnet, BASE 788943e, one commit per task)
|
||||||
|
- Tasks 3–5: review (sonnet): all Approved (858aa6a, a3a8070, b389f98 after trailer normalization; 1617 tests); global id in outline/node-get = library post ID only (accepted, like GlobalUsages); minors → final wave: preset-cap note only when presets requested; Outline duplicates Block::shortName prefix strip
|
||||||
|
- Tasks 3, 4, 5: complete (788943e..b389f98)
|
||||||
|
- Tasks 6–7: dispatched (opus, BASE b389f98, one commit per task)
|
||||||
|
- Tasks 6–7: implementer done (ce1ad7c, 2700fd6; 1646 tests); T7 deviations: unifyLineBreaks skips opaque blocks; deglobalize keeps invalid blockName instance opaque
|
||||||
|
- Tasks 6–7: review (opus): both Approved; byte fidelity independently verified incl. deglobalize == Divi's own PortabilityPost output; deviations accepted
|
||||||
|
- Ruling P11 (T6/T7 minors): → T12/T13 dispatch: refuse opaque/global roots before GlobalInstance::of (no nested instances); → final wave: try/catch Throwable around Divi calls in DiviLibraryStore/DiviRoles (E15 never fatal), stdClass localAttrs, self-closing of() for created childless roots, replaceChild attached-check, isSpecialtyColumn by effectiveName
|
||||||
|
- Tasks 6, 7: complete (b389f98..2700fd6)
|
||||||
|
- Task 8: dispatched (opus, BASE 2700fd6; P9 library-group hiding hook)
|
||||||
|
- Task 8: implementer done (48ecc62; 1660 tests); P9 hook LibraryAccess $visible + ExposurePolicy::groupEnabled, wired as libraryVisible (GROUP 'divi-library', live with T10); T9 must merge: $libraryWritable param + LibraryAccess::GROUP already exist
|
||||||
|
- Task 8: review (opus): Approved; live with temp editor/author/contributor/subscriber + role-editor right withdrawn; P9 hook verified with real ExposurePolicy
|
||||||
|
- Ruling P12 (T8 I-1): users without the Divi library right at all (canList false) get PostGuard::notFound (no existence/type oracle, like P9); missing edit_post on an entry stays `forbidden` (project convention for pages) — fix round 1 now — cost if wrong: less helpful hint for users lacking the role right
|
||||||
|
- Ruling P13 (T8 M-1/M-2 → final wave / T10): with the library group hidden, page outline/node-get omit global_id and the render-summary hint; T10 keeps groupEnabled semantics (group mode only)
|
||||||
|
- Task 8: fix round 1 (P12; 681bb75; 1661 tests; live: logged-out 1058 vs 99999 byte-identical not_found) — verified by controller against report + live claim
|
||||||
|
- Task 8: complete (2700fd6..681bb75). Note for T10+: canList()/canRead() still return forbidden when called directly (fine for list tool — no ID oracle)
|
||||||
|
- Task 9: dispatched (opus, BASE 681bb75; F2, P1, P8, P9 backup refusal + schema)
|
||||||
|
- Task 9: implementer done (d66ad36; 1684 tests; divi-write smoke ok; live dry run on 1059 lists 1053, bytes unchanged); concerns: backup id not named on failure after backup; P8 published only
|
||||||
|
- Task 9: review (opus): live verified dry run/confirm/revision/reference semantics/P8 refusal/no flags; changes requested: I-1 P8 gap (private page: editor without rights changed what it shows), I-2 backup_id lost on failed save after backup
|
||||||
|
- Ruling P8a (amends P8): confirmed global edit requires edit_post on every affected post with status publish, private or future (incl. TB layouts/other library entries found by the query); drafts/pending listed in dry run but not blocking — private/future render or will render — cost if wrong: editors blocked more often
|
||||||
|
- Ruling P14: WriteFailed after a backup carries backup_id + hint (WriteFailed::withDetails); dry run warns with blocked_ids when P8 would refuse (M-1); plural fix (M-2); shared status-parameterised query (M-3); _prime_post_caches before P8 loop (M-4); M-5/M-6 → final wave/T16
|
||||||
|
- Task 9: fix round 1 dispatched
|
||||||
|
- Task 9: fix round 1 (P8a, P14, M-1..M-4; 520a639; 1691 tests); re-review running
|
||||||
|
- Task 9: re-review clean (live: private/future block, draft not, blocked_ids in dry run, backup_id on failed save)
|
||||||
|
- Task 9: complete (681bb75..520a639)
|
||||||
|
- Task 10: dispatched (opus, BASE 520a639; P12, P13 if cheap, per-entry filtering)
|
||||||
|
- Task 10: implementer done (b7c7149; 1702 tests); P12 via item() for id; P13 → final wave
|
||||||
|
- Task 10: review (sonnet): Approved; live: group disabled → list unregistered + read tools not_found; nur lesen → list ok, writes forbidden; P12 id byte-identical; minor: MAX_SCAN cap silent → final wave
|
||||||
|
- Task 10: complete (520a639..b7c7149)
|
||||||
|
- Task 11: dispatched (opus, BASE b7c7149; P2, P3, stored $variable refs via VariableRef)
|
||||||
|
- Task 11: implementer done (f8275ad; 1741 tests; 30 malicious rows); concerns: readable global instances checked (localAttrs) not refused (per brief/preflight I-3); builder-written $variable refs often refused for editors (stricter than Divi); plain text kses-checked only when containing '<'; ValueCleaner::kindAt added
|
||||||
|
- Ruling P15: accept checking readable global instances (a reference renders existing library content; Divi lets library-right users insert them too); stricter $variable handling accepted (visible refusal, documented in T16) — cost if wrong: editors can't copy some builder nodes
|
||||||
|
- Task 11: review (opus, SEC): Approved (guard holds for P3/P15; Divi renders instances from published entries only, localAttrs+snapshot both checked). NEW Critical (pre-existing since 2b): module.decoration.interactions writable by editors → effect addAttribute onclick="…" → Divi's interactions script setAttribute → script on click; path is known in catalog so lit/divi-update can write it
|
||||||
|
- Ruling P16: add decoration.interactions to ValueCleaner::BUILDER_ONLY (S7 gate: nur im Divi-Builder für Benutzer ohne unfiltered_html) — closes main pipeline and StoredNodeGuard; separate commit with tests (full form, short form if any, stored guard, removal via null); also scan the catalog for other paths whose values Divi turns into attributes/HTML/script (e.g. other effect/attribute/script-like option groups) and gate them the same way, listing what was found — cost if wrong: editors can't set interactions via MCP
|
||||||
|
- Ruling P17 (T11 minors): guard URL rule `url($v) === trim($v)`; guard enforces VALUE_KEY key names and digits-only globalModule; STATE_KEYS reuses AttrWriter::STATES; >120-char test lines — in the same fix round
|
||||||
|
- Task 11: fix round 1 (P16 9d92808 interactions builder-only + scan: nothing else to gate; P17 e808832; 1748 tests; smoke ok)
|
||||||
|
- Ruling P18 (P17 too strict for relative links): StoredNodeGuard URL accept rule = url($v) === trim($v), OR the value has no scheme (no ':' before the first '/', '?', '#') and contains no whitespace, quotes, '<', '>' or control chars — relative links (kontakt/, /pfad, #anker, ?q=1) stay copyable, every scheme still must survive url() unchanged — cost if wrong: none (scheme-less values can't carry javascript:)
|
||||||
|
- Task 11: fix round 2 (P18; 16c6d59; 1750 tests); backslash hosts also refused, //host must survive url()
|
||||||
|
- Task 11: re-review: P16/P17/P18 verified live (interactions refused for editors incl. null removal + guard; 63 URL paths × entity-colon payloads safe; relative rule holds); NEW Important: divi/lottie lottie.innerContent.src → remote JSON loaded by lottie-web with expressions (eval) → script; editors could set it
|
||||||
|
- Ruling P19: divi/lottie lottie.innerContent (src and any animation-source keys) builder-only for users without unfiltered_html (same S7 gate as P16), in main pipeline and StoredNodeGuard; tests set/null/copy; guides list it — cost if wrong: editors can't set Lottie via MCP
|
||||||
|
- Task 11: fix round 3 (P19; 8fa285a; 1753 tests; smoke ok) verified by controller (pattern + tests)
|
||||||
|
- Note Etappe 4: Divi global presets can carry a Lottie src / interactions — preset writes (design system) must apply the same builder-only gates
|
||||||
|
- Task 11: complete (b7c7149..8fa285a)
|
||||||
|
- Task 12: dispatched (opus, BASE 8fa285a; P6, P7, P11, guard)
|
||||||
|
- Task 12: implementer done (5035092; 1783 tests; live: bytes equal Divi entry 1055, page unchanged, no flags); nested globals kept as references in normal entries (plan-conform, accepted)
|
||||||
|
- Task 12: review (opus): Approved; live: rights (edit_post source, publish_posts, divi_library/save_library, edit_global_library), guard refuses all builder-only kinds as editor, P11, P6 rollback + partial_write, terms/meta identical to Divi, exposure; bytes equal after CRLF→LF; minors → final wave: REASON_CREATE placement, test line length, partial_write hint for new entries
|
||||||
|
- Task 12: complete (8fa285a..5035092)
|
||||||
|
- Task 13: dispatched (opus, BASE 5035092; P5, P11, guard in copy mode, direct budget 22)
|
||||||
|
- Task 13: implementer done (7fe91c2; 1799 tests; smoke ok; live: 1059/1058 instance and 1055 copy byte-identical to fixture, probes cleaned)
|
||||||
|
- Task 13: review (opus): Approved; live: P5 (author vs admin/private/fixture forbidden, missing not_found), P11 incl. instance into global entry refused by shapeViolation, copy-mode guard refusals as editor, exposure 22 direct; minors → final wave (test line length, multi-root unit test, "zu bearbeiten" wording)
|
||||||
|
- Task 13: complete (5035092..7fe91c2)
|
||||||
|
- NEW (pre-existing, Important): editor saves via any Divi write tool rewrite untouched <p> → <p> (some content_save_pre filter still active despite kses removal); no CHANGED_ON_SAVE; tree_rev may mismatch stored content → investigation dispatched (opus) in parallel with Task 14
|
||||||
|
- Task 14: dispatched (opus, BASE 7fe91c2); parallel: editor-rewrite investigation (read-only on code)
|
||||||
|
- Task 14: implementer done (6f6c691; 1814 tests; smoke ok); source_tree_rev required check; not live-probed
|
||||||
|
- Investigation editor rewrite: NOT reproducible (45 inputs, editor+admin, plain wp_update_post in kses window and end-to-end lit/divi-insert: byte-identical, tree_rev == Rev::of(stored)); likely tool-chain \u decoding in the reviewer's probe (known artifact); CHANGED_ON_SAVE detection works (wp_strip_custom_css_from_blocks + Divi AttributeSecurity both detected). Ruling P20: no kses-window change; final wave adds a file-based editor regression test and switches synthetic-divi-filters.html to \u003c escapes — cost if wrong: none
|
||||||
|
- Task 14: review (opus): Approved; live via registered ability: source access P5/P12, guard refusals as editor (7 kinds), P11, same-page byte identity, restore point, source/target rev locks, exposure matrix; minors → final wave: shared source-node helper (dup with LibrarySave), OUTLINE_HINT reuse, test line length, 'after'=>null in test
|
||||||
|
- Task 14: complete (7fe91c2..6f6c691)
|
||||||
|
- Task 15: dispatched (opus, BASE 6f6c691; F4, P9 unknown types, allow-map equivalence test)
|
||||||
|
- Task 15: implementer done (8808a7d; 1827 tests; allow map byte-identical JSON snapshot); concerns → final wave: {{link:}} hint for attachments vs P10, choice fields show value not label (document)
|
||||||
|
- Task 15: review (sonnet): Approved; allow-map snapshot test mutation-verified (non-vacuous); sample post only editable by user (implementer fixed brief leak); live page 26 sources
|
||||||
|
- Task 15: complete (6f6c691..8808a7d)
|
||||||
|
- Task 16: dispatched (opus, BASE 8808a7d; docs must describe actual T1–T15 behaviour, collected points in dispatch)
|
||||||
|
- Task 16: implementer done (2729e28; 1829 tests; v0.4.0); library line in instructions only when group writable; spec 'Claude' wording → final wave
|
||||||
|
- Task 16 review (sonnet) and Task 17 (opus, BASE 2729e28) dispatched in parallel (disjoint files: docs vs tests/Integration + abnahme note)
|
||||||
|
- Task 16: review (sonnet): Approved (docs verified against code: 22 direct, result keys, P8a/P9/P12/P16/P19/P3/P15–P18, link rules, version, topics, neutral wording); → final wave: wp-lit-mcp.php plugin header still says "mit OAuth für claude.ai"; internal spec "Claude" wording
|
||||||
|
- Task 16: complete (8808a7d..2729e28)
|
||||||
|
- Task 17: done (186f7af; 6 smokes ALL CHECKS PASSED; 1829 tests); Ruling P21: T17 (test script + acceptance note only) is reviewed within the final whole-branch review instead of a separate task review — cost if wrong: none
|
||||||
|
- Task 17: complete (2729e28..186f7af)
|
||||||
|
- Final: whole-branch review (opus) + security audit (opus) dispatched in parallel on cfc81dd..186f7af
|
||||||
|
- Final review (opus): ready with fixes; must: I-1 node-copy from library skips add_library (+ library-insert exposure), I-2 P13 guide vs code
|
||||||
|
- Security audit (opus): no Critical/High/Medium; Low: L1 DoS in P8a/usages (loads full posts), L2 referenced-but-not-global entry skips dry run/P8a (live), L3 library-list search covers content + decodable cursor → existence oracle for authors (live), L4 dry run lists id/status/type of unreadable posts, L5 = I-1, L6 TB layouts accepted on WP caps only; hardening: guard vs first globalModule id, confirm not bound to dry run, "0123" refs
|
||||||
|
- Ruling P22: final fix wave per final-fix-brief.md (must + audit L1–L6 + cheap review items); P13 full hiding and P10 → Etappe 3b; guide sentence fixed now — cost if wrong: none
|
||||||
|
- Final fix wave: 17/17 (1b42f9b..f1937ca; 1868 tests; 6 smokes ok); decisions: referenced normal entries get dry run/confirm (drafts listed, only live block, no edit_global_library needed); cursor counts shown entries only; divi/global-layout check via Divi module list; link hint dropped for internal types; divi-write-smoke roles-option restore same issue → U; scoped re-review dispatched
|
||||||
|
- Final fix re-review (opus): items 1–17 addressed (L1/L3/L4/L6/item 8 live-verified); F1 open: usage search misses Divi 4 shortcode global_module="ID" and JSON-escaped globalModule key/value (Divi renders them) → author edited an entry used only on such admin pages without dry run/P8a (live); minor: 'Eintrag, die' → 'den'
|
||||||
|
- Ruling P23: GlobalUsages finds references fail-closed: SQL prefilter candidates = content containing 'globalModule' OR 'global_module' OR a Divi block opener with '\\u' escapes; then PHP-parse candidates: Divi 4 shortcodes (global_module attr, intval) and Divi 5 openers (json_decode attrs → globalModule intval); an escaped opener whose globalModule can't be decoded counts as a possible reference (blocking if not editable); guide sentence adjusted; wp_block embedding documented as parity (Info) — cost if wrong: more pages listed/blocking on odd content
|
||||||
|
- Final fix round 1 (P23; fec5d9d, 8f87ec6; 1887 tests; smokes ok): candidate prefilter globalModule/global_module/escaped ASCII letter; UsageMatcher parses Divi 5 (json_decode) + Divi 4; undecodable escaped opener = possible reference; parse cap 5000
|
||||||
|
- F1 re-check: live bypass fixed, prefilter not evadable; NEW G1 Divi 4 shortcode inside a Divi 5 attribute value (\u0022-escaped quotes) misparsed; G2 leading space / scientific notation cast by Divi (int); G3 global_module values casting to 0 render the NEWEST published layout (et_pb_load_global_module WP_Query p=0); _bin collation note
|
||||||
|
- Ruling P24: UsageMatcher scans decoded string values of every Divi 5 opener for Divi 4 shortcodes too; shortcode attrs parsed like WordPress (shortcode_parse_atts) and cast with (int) exactly like Divi; a global_module that casts to 0 counts as a reference to EVERY library entry (fail closed; rare); prefilter case-insensitive regardless of collation (LOWER() or explicit _ci COLLATE); guide wording without absolute claim; after this round remaining exotic forms are documented Low (breaker) — cost if wrong: coarse over-listing on odd content
|
||||||
|
- Final fix round 2 (P24; b6d791d; 1901 tests; smokes ok; live: shortcode in Divi 5 text value with ", leading space, abc, GLOBAL_MODULE found; other-ID control not found)
|
||||||
|
- Ruling P25 (breaker): accept after round 2 without another re-review — fail-closed rule in place, remaining exotic spellings need edit rights on the referencing page, documented as residual Low in the guide and rulings; G3 side effect (an invalid global_module on a real page makes every entry edit need confirm) accepted as fail-closed — cost if wrong: a crafted unusual spelling on a page the author can't edit escapes P8a (Low)
|
||||||
|
- Etappe 3a complete: cfc81dd..b6d791d
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Etappe 3a (Divi-Bibliothek): security audit
|
||||||
|
|
||||||
|
- **Scope:** branch etappe-3, cfc81dd..186f7af. Focus: what the per-task reviews covered least (object authorization, global edits, stored-content injection, disclosure, DoS).
|
||||||
|
- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1), single site, no editors/authors present, `DISALLOW_UNFILTERED_HTML` off.
|
||||||
|
- **Method:** read all new/changed `src/` files of the range plus the relevant Divi 5 render code (`BlockParser`, `BlockParserStore`, `GlobalLayout`, `DynamicContentOptions*`, `LinkUtils`, MultiView, Divi 4 `class-et-builder-element.php`), scanned all `script-library-*.js` for DOM sinks, and ran four self-cleaning `wp eval-file` probes (temp posts 1906–1914, temp user `litaudit_author`; all deleted, verified: 0 `LITAUDIT` posts, 0 postmeta/term rows for those IDs, no temp user, no probe files, `git status` clean). No [Fixture]/[Test] content was touched; no code was changed.
|
||||||
|
|
||||||
|
**Result:** no Critical, High or Medium findings. The rulings P3/P5/P8a/P11/P12/P15–P19 hold. Six Low findings and three Info/hardening notes follow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings (most severe first)
|
||||||
|
|
||||||
|
### L1 – Low (DoS): the P8a check loads every live usage with full content, and each dry run scans `wp_posts` 3×
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Library/GlobalUsages.php:68-79` (`liveIds()`: no LIMIT).
|
||||||
|
- `src/Integrations/Divi/Library/GlobalEdits.php:158-171` (`_prime_post_caches($live, …)`, then `current_user_can('edit_post')` per ID).
|
||||||
|
- `GlobalUsages.php:29-47`: COUNT plus SELECT, each an unindexed `post_content LIKE '%…%'`.
|
||||||
|
- Also reached from `GlobalEdits::notice()` (every delete dry run) and `LibraryListAbility::detail()`.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. A global module (newsletter box, CTA) sits in thousands of posts. That is a common Divi pattern.
|
||||||
|
2. Any user with library rights and `edit_post` on the entry calls a Divi tool on it without `confirm` (dry run), or `lit/library-list` with `id`.
|
||||||
|
3. `_prime_post_caches()` fetches `posts.*` for every live ID in one query, `post_content` included. At 5,000 posts × ~50 KB of Divi markup that is about 250 MB, which exceeds the usual `memory_limit` and ends the request in a fatal error.
|
||||||
|
4. Each call also runs 3 full-table LIKE scans (6 on confirm, because dry run and confirm both call `of()` and `blocked()`). A loop of calls ties up the database on large sites.
|
||||||
|
5. The effect stops when the requests stop, and it needs an authenticated MCP user.
|
||||||
|
|
||||||
|
**Evidence:** by reading. The DDEV data set is too small to reproduce the memory limit.
|
||||||
|
|
||||||
|
**Fix**
|
||||||
|
- Use one query: `SELECT ID, post_status, post_type, post_author, post_title … LIKE …`. Derive `count`, the first 50 items and the live list in PHP, instead of COUNT + SELECT + `liveIds()`.
|
||||||
|
- Evaluate `edit_post` in chunks of about 200 IDs, calling `_prime_post_caches()` per chunk and `clean_post_cache()` or `wp_cache_flush_runtime()` between chunks.
|
||||||
|
- Or fail closed: above a hard cap (e.g. 1,000 live usages), refuse the confirmed edit with a hint ("im Divi-Builder bzw. von einem Administrator bearbeiten").
|
||||||
|
|
||||||
|
### L2 – Low (global edits): "global" is decided by the scope term, but Divi renders every referenced entry, so edits to referenced non-global entries skip the dry run and P8a
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Library/GlobalEdits.php:71` and `:88-90`: `if (! $global) return LibraryItem`.
|
||||||
|
- `src/Integrations/Divi/Library/DiviLibraryStore.php:37` (`isGlobal()` → `GlobalLayout::is_global_layout_template()`, which checks the scope term, with a meta fallback for modules only).
|
||||||
|
- Divi: `BlockParser.php:838-880` renders any `divi/global-layout` whose `globalModule` names a *published `et_pb_layout`*. It checks only `post_type` and `post_status`, not the scope.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. A live page that user U cannot edit holds an instance of library entry N, but N has no `global` scope term. This can happen when:
|
||||||
|
- the entry comes from an import (Divi's D4 fallback covers modules only, not sections or rows);
|
||||||
|
- the scope was changed later;
|
||||||
|
- the markup was written outside the Divi builder.
|
||||||
|
2. U has `edit_post` on N (as its author, or as an editor) and changes N with any Divi tool.
|
||||||
|
3. `GlobalEdits` treats N as a normal entry: no dry run, no `affected` list, no P8a check, no site-wide CSS clear.
|
||||||
|
4. The page U cannot edit now shows the changed content.
|
||||||
|
|
||||||
|
**Evidence (live)**
|
||||||
|
- Temp entry 1910 (`scope = non_global`) and temp published page 1911 with `<!-- wp:divi/global-layout {"globalModule":"1910","blockName":"divi/text"} /-->`.
|
||||||
|
- `isGlobal(1910) = false`, while `GlobalUsages::liveIds(1910) = [1911]`.
|
||||||
|
- The frontend HTTP render of 1911 shows the marker from 1910 (`LITAUDIT_NORMAL_ENTRY`).
|
||||||
|
- Both posts were deleted afterwards.
|
||||||
|
|
||||||
|
**Fix:** in `GlobalEdits::prepare()` and `notice()`, apply the global rules (confirm, `affected`, P8a, `GlobalItem` scope) whenever `isGlobal()` is true **or** `GlobalUsages` finds at least one usage. For the check, one `LIMIT 1` query is enough.
|
||||||
|
|
||||||
|
### L3 – Low (disclosure): the `lit/library-list` cursor is an oracle over the *content* of entries the caller may not read
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Library/LibraryListAbility.php:176-194`: rows the caller may not read still advance `$position`, and `next_cursor = Cursor::encode($position)` is `base64("o:N")`.
|
||||||
|
- `src/Integrations/Divi/Library/DiviLibraryStore.php:103`: `'s' => search` makes WP search title, excerpt **and content**, although the schema at `LibraryListAbility.php:68` says "Suche im Titel".
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. An author with the Divi library right creates two entries of their own that contain candidate words.
|
||||||
|
2. They then search with `limit: 1`.
|
||||||
|
3. The decoded cursor tells how many hidden entries match the search and sort before their own. That lets them guess words from other users' library content (codes, internal notes).
|
||||||
|
4. Plugin rule E3 / P12 is that authors may not read those entries: `lit/library-list id=…` returns `lit_forbidden`.
|
||||||
|
5. Mitigation: Divi's own Visual Builder library lists all entries to every user with the library right. Relative to Divi this is parity; relative to the plugin's own stricter rule it is a bypass. It is the same defect class that `PostGuard::ownPostsOnly()` already documents.
|
||||||
|
|
||||||
|
**Evidence (live)**
|
||||||
|
- Temp author and 3 temp entries: an admin entry "aaa" containing `LITAUDITSECRET pin 4711`, and author entries "zzz1" and "zzz2" containing `LITAUDITSECRET`.
|
||||||
|
- As the author, `search=LITAUDITSECRET, limit=1` returns items `["LITAUDIT zzz1"]` with `next_cursor=bzoy` (= `o:2`, which reveals one hidden match).
|
||||||
|
- A direct read of the admin entry returns `lit_forbidden`.
|
||||||
|
- All temp data was deleted and the deletion verified.
|
||||||
|
|
||||||
|
**Fix**
|
||||||
|
- Restrict the query in `find()`:
|
||||||
|
- add `'author' => PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE)` when it is non-null, so the cursor counts only rows the caller can see;
|
||||||
|
- set `'search_columns' => ['post_title']` (WP ≥ 6.2) to match the documented title-only search.
|
||||||
|
- Keep the per-row `canRead()` filter as defence in depth.
|
||||||
|
|
||||||
|
### L4 – Low (disclosure): `affected`, `blocked_ids` and the P8a refusal expose IDs, status and type of posts the caller may not read
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Library/GlobalUsages.php:48-56`: title masked, but `post_id`, `status` and `type` are always returned.
|
||||||
|
- `src/Integrations/Divi/Library/GlobalEdits.php:184-203`: IDs in message and details.
|
||||||
|
- Also `lit/library-list id=…` (`affected`).
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. An author owns a global entry. An administrator used it on private pages and on other users' drafts.
|
||||||
|
2. The author's dry run lists those posts' IDs, their status (`private`, `draft`, `future`) and their post type.
|
||||||
|
3. Only the titles are hidden.
|
||||||
|
|
||||||
|
**Evidence:** by reading. The masking applies to `post_title` only.
|
||||||
|
|
||||||
|
**Fix**
|
||||||
|
- For rows without `read_post`, return only an aggregate: `hidden_count`, plus the count of blocking hidden posts.
|
||||||
|
- Name blocked IDs only where `read_post` holds; otherwise say "N Einträge, die dieses Konto nicht lesen darf".
|
||||||
|
|
||||||
|
### L5 – Low (authorization): `lit/divi-node-copy` from a library entry into a page skips Divi's role option `add_library`
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:78-96`: the source goes through `DiviPageLoader::load(…, false)`, i.e. `LibraryAccess::item()` / `canRead()` (`edit_posts`, `divi_library`, `edit_post`).
|
||||||
|
- Compare `LibraryAccess::canInsert()` (`add_library`), which `lit/library-insert` requires.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. The site owner turns off "Aus der Bibliothek hinzufügen" (add from library) for a role in Divi → Rollen-Editor.
|
||||||
|
2. A user of that role can still copy any node from an entry they can edit into a page, via `lit/divi-node-copy` with `source_post_id = <library entry>`.
|
||||||
|
3. Security impact is small, because content still passes StoredNodeGuard. But it breaks E3 ("nie laxer als Divi", never laxer than Divi).
|
||||||
|
|
||||||
|
**Evidence:** by reading.
|
||||||
|
|
||||||
|
**Fix:** in `precheck()`, when the loaded source `isLibraryItem()`, additionally return `$access->canInsert()`. The target side is already covered by `canEdit()` / `save_library`.
|
||||||
|
|
||||||
|
### L6 – Low (global edits): P8a accepts Theme Builder layouts on WordPress `edit_post` alone
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Library/GlobalEdits.php:158-171`.
|
||||||
|
- On DDEV, `et_header_layout`, `et_template`, `et_theme_builder` and `et_pb_layout` all use capability type `post` with `map_meta_cap`. So an editor has `edit_post` on the site header and footer layouts.
|
||||||
|
- Divi gates the Theme Builder with the role option `theme_builder`, which is off by default for every role but administrator.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. A global module sits in the Theme Builder header, footer or body.
|
||||||
|
2. An editor with `edit_global_library` passes P8a, because they have `edit_post` on the TB layout.
|
||||||
|
3. The confirmed edit changes the site-wide header, although Divi does not let this editor use the Theme Builder.
|
||||||
|
4. Divi's own Visual Builder allows the same global edit from any page (parity with Divi). But P8a's intent, "changes only on posts this account may edit", is not met.
|
||||||
|
|
||||||
|
**Evidence:** live, post type objects and caps (probe p1). The rest is by reading.
|
||||||
|
|
||||||
|
**Fix:** in `blocked()`, also treat IDs whose post type is one of `et_header_layout`, `et_body_layout`, `et_footer_layout` or `et_template` as blocked unless `DiviRoles::allows('theme_builder')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Info / hardening
|
||||||
|
|
||||||
|
### I1 – StoredNodeGuard trusts the instance's own `blockName` and `globalModule`; Divi renders the real entry
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Divi/Compose/StoredNodeGuard.php:90-91` and `:114-138`. Routing and the code-module check use `GlobalInstance::effectiveName()`, i.e. the stored `blockName`.
|
||||||
|
- `Json::decodeObject()`: the last duplicate key wins.
|
||||||
|
|
||||||
|
**What Divi does**
|
||||||
|
- `BlockParser.php:838` takes the *first textual* `"globalModule":"(\d+)"`. That can be a nested one inside `localAttrs`, or the first of two duplicate keys.
|
||||||
|
- `BlockParserStore.php:1113ff` merges `localAttrs` (filtered by the entry's `localAttrsMap`) onto the entry's *actual* root block.
|
||||||
|
|
||||||
|
**Consequence**
|
||||||
|
- A forged instance can be routed as `divi/text` (kses-checked richtext) while it is rendered onto a `divi/code` entry, or onto another entry than the guard saw.
|
||||||
|
- Exploiting this needs a forged instance in a source the user can edit. The user can already write that into their own page through core REST (parity, cf. 2b audit M1). The new part is that node-copy can carry it into a library entry (`et_pb_layout` has `show_in_rest = false`).
|
||||||
|
|
||||||
|
**Hardening**
|
||||||
|
- Refuse instances whose JSON contains more than one `"globalModule"` occurrence.
|
||||||
|
- Resolve `globalModule` to a published `et_pb_layout` the user may read.
|
||||||
|
- Require its root block name to equal `blockName`, and route `localAttrs` by that real name.
|
||||||
|
|
||||||
|
### I2 – Confirm is not bound to the dry run
|
||||||
|
|
||||||
|
- `GlobalEdits.php:95-117`: between the dry run and `confirm: true`, the `affected` set can grow; the user agreed to N pages, and M are changed.
|
||||||
|
- Rights are safe, because P8a is recomputed at confirm (verified by reading; a live check exists from T9).
|
||||||
|
- Optional: accept `expected_affected_count` (or a hash of the affected IDs) and refuse on mismatch.
|
||||||
|
|
||||||
|
### I3 – GlobalUsages misses non-canonical references Divi still renders
|
||||||
|
|
||||||
|
- `"globalModule":"0123"` renders entry 123: Divi's `([0-9]+)` capture is cast to int by `get_post()`.
|
||||||
|
- The LIKE on `"globalModule":"123"` does not match it.
|
||||||
|
- Only someone who can edit the containing post can write it, so there is no attacker gain.
|
||||||
|
- Optional: LIKE on `'"globalModule":"%'` plus a PHP regex with `ltrim($id, '0')`.
|
||||||
|
|
||||||
|
**Not a gap (checked live):** Divi 4 shortcode pages with `global_module="<id>"`. GlobalUsages does not find them (probe p2/p3: `liveIds = []`), but Divi 5 does not render the D5 entry content there either. The frontend showed the page's own local D4 content, not the entry's. The plugin refuses to edit D4-format entries, so no unlisted page changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Areas checked and found sound
|
||||||
|
|
||||||
|
- **IDOR via `library_id`:** `LibraryInsertAbility::item()` goes through `LibraryAccess::item(false)` (P5/P12): same `not_found` for non-library IDs and revisions, `forbidden` only for users with the library right.
|
||||||
|
- **IDOR via `source_post_id`:** PostGuard (`edit_post`, managed types only; `revision`, TB and library types excluded) or LibraryAccess, re-checked in `execute()`, with its own `source_tree_rev`.
|
||||||
|
- **IDOR via `post_id` in `$variable` settings / `{{link:ID}}`:** `select_post` → `read_post` (`DynamicContentRules.php:246-259`), re-checked by StoredNodeGuard through `canonical()` against the target post. Only `post_link_url_*` uses `select_post` in Divi 5.2.1. `{{link:}}` to `et_pb_layout` is refused by the registry, with a uniform message.
|
||||||
|
- **Divi role options:**
|
||||||
|
- `divi_library`, `save_library`, `add_library` and `edit_global_library` are combined correctly with `edit_posts`, `edit_post` and `publish_posts` in `LibraryAccess` (except L5).
|
||||||
|
- The P9 group gates (writable/visible) are applied in `item()`, `canEdit()`, `canSave()` and `canInsert()`.
|
||||||
|
- **Global edits through replace-text, node-copy into, insert into, and delete in a global entry:**
|
||||||
|
- All go through `DiviWriter::commit()` → `GlobalEdits` (shape, dry run, P8a, backup, `GlobalItem` scope).
|
||||||
|
- replace-text never sends `confirm`.
|
||||||
|
- An instance inside a global entry is refused (P11/`shapeViolation`).
|
||||||
|
- Generic WP tools (`content-update`, `post-trash`, `revision-restore`, `duplicate`) cannot reach `et_pb_layout` (not managed).
|
||||||
|
- **Dry run/confirm race for rights:** P8a is recomputed at confirm from a fresh query. The `tree_rev` lock covers entry content changes.
|
||||||
|
- **Content injection:**
|
||||||
|
- `localAttrs` and the snapshot children of instances are walked, and so are local children, because the parser keeps inner blocks of opaque blocks.
|
||||||
|
- `deglobalize` runs before the guard.
|
||||||
|
- CRLF unification touches whitespace-only text between blocks only.
|
||||||
|
- Invalid or non-`divi/` `blockName` instances are refused as opaque.
|
||||||
|
- Library titles go through `sanitize_text_field` (plus core title kses).
|
||||||
|
- Terms are limited to existing names.
|
||||||
|
- **Divi frontend sinks beyond interactions and Lottie** (scan of all `script-library-*.js`):
|
||||||
|
- module link → `diviElementLinkData`, where `LinkUtils::generate_data` applies `esc_url` before `window.location`/`window.open`;
|
||||||
|
- multi-view `innerHTML`/`setAttribute`: data via `wp_localize_script` (nested values are not entity-decoded), with fixed attribute names and per-attribute sanitizers;
|
||||||
|
- `new Function` hits are bundler global shims;
|
||||||
|
- magnific-popup, slider and menu read `src`/`href` from escaped markup.
|
||||||
|
- Together with the T11 live URL matrix, no new sink was found.
|
||||||
|
- **Disclosure:**
|
||||||
|
- `dynamic-sources` samples only an editable post, uses the `edit` context (custom meta hidden without `read_dynamic_content_custom_fields`) and returns no option defaults.
|
||||||
|
- Render-summary notes list only IDs already present in content the caller may read.
|
||||||
|
- site-status `problems` are labels of missing Divi internals only.
|
||||||
|
- Error texts are uniform for missing and unreadable IDs (P12; `{{link:}}` from T2). The known P13 leftovers are left to the final wave.
|
||||||
|
- **Multisite:** everything is per-blog (`$wpdb->posts`, Divi role options, `current_user_can`). No network-level path exists. Without `unfiltered_html` (site admins), the guard and the P9 backup refusal only get stricter.
|
||||||
|
- **DoS (other):**
|
||||||
|
- `lit/library-list` scan is capped (`MAX_SCAN` 500, `limit` ≤ `Cursor::MAX_LIMIT`).
|
||||||
|
- Guide `topic=all` is capped at 100 KB of static files.
|
||||||
|
- `affected` is capped at 50 rows (the cost is in L1, not the output).
|
||||||
Reference in New Issue
Block a user