Salesforce-Marketing-Cloud-Formulare als Canvas-SDC mit Host-Script-Hoisting und CMP-Domain-Consent
byte_theme:salesforce-marketing-form mit einem Prop embed_code eingebettet. Der Prop nutzt pattern: '(.|\r?\n)*' und wird so von Canvas als ungefilterte string_textarea gespeichert (kein filter_html, das <script> strippen würde); das Twig gibt ihn mit |raw aus. Die published Canvas-Seite ist server-seitig gerendert, daher landet der Snippet im HTML-Quelltext und die Scripts laufen. Das konstante Host-Loader-Script wird in RenderCallbacks::preRenderComponent aus dem Snippet gestrippt und stattdessen als dedupliziertes Header-Library byte_theme/salesforce-forms-host einmal pro Seite geladen; das pro-Formular-Lightning-Out-Script (inkl. onload-Handshake) und das <fragment-…>-Element bleiben inline. Consent läuft CMP-agnostisch über Domain-Blocking auf solcom.my.site.com, konfiguriert in der Usercentrics-Konsole (F-300). Achtung: Es gibt aktuell kein aktives Gating — Klaro ist dormant im Silent-Mode, Usercentrics noch nicht live. Die Komponente darf daher erst dann auf eine öffentliche Seite, wenn das Domain-Blocking konfiguriert ist; sonst feuert das Tracking ungated. Die Sicherheit ist hier operativ, nicht im Code erzwungen.
Kontext
SOLCOM will Salesforce-Marketing-Cloud-Formulare (Newsletter-/Marketing-Anmeldungen) in Canvas-Seiten einbetten. Der von Salesforce erzeugte Embed-Snippet besteht aus drei Teilen:
- einem konstanten Host-Loader-Script
external-forms-host.min.js(identisch für jedes Formular dieser SF-Org); - einem pro-Formular-Lightning-Out-Script mit wechselnder ID und einem inline
onload="window.sfdc_forms?sfdc_forms.init():window.sfdc_forms={loReady:!0}"-Handshake; - einem Custom-Element
<fragment-…>mit pro-Formular eindeutigem Tag-Namen — der eigentliche Form-Container.
Das Formular trägt data-web-tracking="true" und lädt Code von solcom.my.site.com — es tracked aktiv und ist damit consent-pflichtig.
Drei harte technische Randbedingungen wurden vorab verifiziert und bestimmen die Architektur:
- Canvas-Text-Filter strippen
<script>. Alle Text-Formate (canvas_html_blocketc.) jagen Prop-Inhalte durchfilter_html; eincontentMediaType: text/html-Prop würde den Snippet zerstören. Auch Slot-Defaults werden überXss::filterAdmin()gefiltert. - Per innerHTML injizierte
<script>-Tags werden vom Browser nicht ausgeführt. Die Scripts müssen server-seitig in den HTML-Quelltext gerendert werden. Die published Canvas-Seite ist genau das:ComponentTreeItemList::toRenderable()rendert den Component-Tree server-seitig via Twig (Client-Hydration nur im Editor). - Kein generischer XSS-Filter auf SDC-Props.
ComponentElementkopiert#propsohne erneute Filterung in den Twig-Context; einstring-Prop ohnetext/html-MediaType bleibt verbatim erhalten.
Der Product Owner hat die Topologie-Entscheidung getroffen: SDC (nicht Block-Plugin). Zusätzlich wurde festgelegt, dass das wiederkehrende Host-Script zentral in den <head> gehört und das Projekt für Consent auf Usercentrics wechselt (Story F-300; bislang nur ein dormanter Klaro im Silent-Mode).
Entscheidung
1. SDC mit einem ungefilterten embed_code-Prop
Die neue SDC byte_theme:salesforce-marketing-form hat genau einen Prop embed_code vom Typ string mit pattern: '(.|\r?\n)*'. Dieses Pattern lässt Canvas den Prop als string_long-Feld mit string_textarea-Widget und Expression …␟value (nicht …␟processed) speichern — also ohne allowed_formats/filter_html. Der Redakteur fügt den kompletten Salesforce-Snippet in die Textarea ein; <script> und <fragment-…> überleben verbatim. Das Twig gibt den Wert mit |raw innerhalb eines <div class="salesforce-marketing-form"> aus. {% if embed_code %} verhindert ein leeres Wrapper-Div.
2. Host-Script-Hoisting via RenderCallbacks::preRenderComponent
Das konstante Host-Loader-Script ist für jedes Formular identisch und soll genau einmal pro Seite laden. Statt es inline pro SDC-Instanz zu duplizieren, wird es in RenderCallbacks::preRenderComponent (demselben bestehenden #pre_render-Seam, der schon den Hero-LCP-Preload macht) aus dem Snippet gestrippt und als Library byte_theme/salesforce-forms-host (header: true, type: external, async) attached. Drupal dedupliziert Libraries automatisch → ein <head>-Eintrag, egal wie viele Formulare auf der Seite liegen. Das pro-Formular-Lightning-Out-Script und das <fragment-…> bleiben inline, der onload-Handshake also intakt.
Wichtiges Render-Pipeline-Detail: Cores ComponentElement-pre_render läuft vor diesem angehängten Callback und backt #props bereits in den inline-template-Context. Eine Mutation von #props käme zu spät — der Strip greift daher am bereits gebauten inline-template['#context']['embed_code']. #attached bubbelt unabhängig von der Callback-Reihenfolge hoch. Der Callback ist auf die eine Komponente gegated, damit Hero & Co. unberührt bleiben (ein Prepend vor Core hätte die Hero-Logik in der Reihenfolge verändert und ist deshalb verworfen).
3. CMP-agnostisches Consent-Gating über Domain-Blocking
Die externen Scripts werden allein über ihre Domain solcom.my.site.com gegated — es gibt keine CMP-spezifischen data-usercentrics/data-name-Attribute in der SDC. Zielzustand (Variante A): ein Usercentrics-Data-Processing-Service „Salesforce Marketing Cloud" mit Domain-Auto-Blocking auf solcom.my.site.com, konfiguriert in der Usercentrics-Konsole — kein Code-Change an der SDC. Der Wechsel des CMP (heute dormanter Klaro, künftig Usercentrics) bleibt damit eine reine Konsolen-/Config-Sache.
4. Trust-Boundary für |raw
embed_code ist ein Trusted-HTML-Sink, äquivalent zum Full-HTML-Textformat. Die Sicherheitseigenschaft gilt ausschließlich, solange das Canvas-Editing auf Rollen mit redaktioneller Vertrauensstufe beschränkt bleibt. Verifiziert (Stand dieses ADR): nur content_editor und content_manager tragen die Canvas-Editing-Rechte; keine nicht-vertrauenswürdige Rolle hat Zugriff. Eine spätere Ausweitung dieser Rechte auf eine offenere Rolle würde diesen Prop zu einem Stored-XSS-Vektor machen und ist nur zulässig, wenn der Prop dann anders abgesichert wird.
Abgelehnte Alternativen
Block-Plugin statt SDC
Ein Custom-Block mit Config-Formular (Textarea oder strukturierte Felder), der den Snippet im build() emittiert. Technisch ebenfalls tragfähig (umgeht Filter, kann via #attached['html_head'] server-rendern) und war die ursprüngliche Empfehlung. Vom Product Owner zugunsten einer SDC verworfen — die SDC fügt sich in die bestehende byte_theme-Komponenten-Vokabularik (77 SDCs) ein und ist im Canvas-Editor wie jede andere Komponente platzierbar.
contentMediaType: text/html-Prop mit Canvas-Text-Editor
Ein Rich-Text-Prop wie bei der text-Komponente. Abgelehnt, weil Canvas solche Props über ein text_long-Feld mit allowed_formats: [canvas_html_block] speichert und filter_html <script> sowie das Custom-Element entfernt — der Snippet wäre nutzlos.
Snippet als Slot statt Prop
Den Embed-Code als Slot-Inhalt übergeben. Abgelehnt, weil Canvas Slot-String-Defaults über Xss::filterAdmin() rendert (ComponentTreeItemList), was <script> ebenfalls strippt.
Genereller HTML-Parser zum Aufsplitten des Snippets
Den Snippet per DOMDocument vollständig parsen und alle Scripts programmatisch in den <head> heben. Abgelehnt als Over-Engineering: nur das eine konstante Host-Script muss zentralisiert werden (stabile, bekannte Form → gezielter Regex genügt); das pro-Formular-Script muss ohnehin inline bei seinem Fragment und seinem onload bleiben.
CMP-spezifische Consent-Attribute in der SDC
type="text/plain" data-usercentrics="…" direkt im Twig setzen. Abgelehnt, weil das die SDC an ein konkretes CMP bindet; Domain-Blocking erreicht dasselbe CMP-seitig und überlebt den Klaro→Usercentrics-Wechsel ohne Code-Change.
Konsequenzen
Positiv:
- Redaktionell pflegbar ohne Code-Change pro neuem Formular — ein Paste in die Textarea genügt.
- Host-Script lädt genau einmal pro Seite im
<head>(Library-Dedup), auch bei mehreren Formularen. - Der
onload-Handshake des Lightning-Out-Scripts bleibt unangetastet, weil dieses Script inline bei seinem Fragment bleibt. - CMP-agnostisch: der Klaro→Usercentrics-Wechsel ist reine Konsolen-Config.
- Konsistent mit dem bestehenden Theme-Muster (RenderCallbacks-Hoisting wie beim Hero-LCP-Preload).
Risiken / Kosten:
- Trust-Boundary:
|rawauf einem redaktionellen Prop ist nur sicher, solange Canvas-Editing aufcontent_editor/content_managerbeschränkt bleibt (siehe Entscheidung 4). - Regex-/URL-Kopplung: Der Dateiname
external-forms-host.min.jssteht zweimal — im Strip-Regex (RenderCallbacks::stripSalesforceHostScript) und in der Library-URL (byte_theme.libraries.yml). Beide sind per Kommentar gegenseitig verlinkt; benennt Salesforce den Loader um, müssen beide nachgezogen werden, sonst lädt das Host-Script doppelt (inline + Library). - Kein Live-Preview: Ein leerer/unvollständiger Snippet rendert lautlos nichts (kein Fehler). Die Component-Description weist darauf hin.
Verifikation: 3 Kernel-Render-Tests (SalesforceMarketingFormComponentTest) über den echten #type => component-Pfad — raw-Render des Fragments, Leer-Fall, Host-Strip + Library-Attach + onload-Erhalt. Zusätzlich per realem Render (drush php:script) und Canvas-Tree-Preview (tool_api_preview_canvas_page, keine validation_errors) bestätigt; die generierte canvas.component.sdc.byte_theme.salesforce-marketing-form.yml belegt field_type: string_long + …␟value (ungefiltert).
Offene Punkte
byte_theme/salesforce-forms-host und der inline Lightning-Out-Snippet laden, sobald die Komponente auf einer Seite steht — das Gating hängt vollständig an der CMP-Domain-Config. Da heute weder Klaro (dormant, Silent-Mode) noch Usercentrics solcom.my.site.com blockt, ist der aktuelle Stand ungated — die Komponente darf erst dann auf eine öffentliche Seite, wenn das Domain-Blocking konfiguriert und im Browser verifiziert ist. Andernfalls feuert data-web-tracking="true" ohne Consent. Dies ist ein Live-Launch-Gate, kein Komponenten-Merge-Gate.onload-Erhalt nach Consent (Browser-Verify nach Usercentrics). Manche Auto-Blocker bauen ein Script aus seiner src neu und verwerfen dabei inline onload. Sobald Usercentrics live ist, muss im Browser geprüft werden, dass der Lightning-Out-onload-Handshake den Re-Inject überlebt — sonst initialisiert das Formular auch nach Consent nie.docs/design/MAPPING.html hat keinen Eintrag für salesforce-marketing-form. Für eine Third-Party-Integration-Embed ist das Fehlen eines Figma-Pendants begründbar — sollte aber als bewusste Lücke im MAPPING vermerkt werden.