From 96733c0b59a30fffe718b78efa20384b9256019a Mon Sep 17 00:00:00 2001 From: Sascha Bieberstein Date: Thu, 25 Jun 2026 16:21:11 +0200 Subject: [PATCH] Adds opt-in origin-hardening for cross-frame messages Until now buzz accepted messages from any origin and posted cross-frame messages with targetOrigin '*', so a foreign frame on the same page could inject or skim buzz traffic. A new buzz.configure({allowedOrigins, targetOrigin}) sets a document-wide, opt-in trust boundary. When configured, the Connector listener and the uplink drop cross-frame messages from disallowed origins, and the uplink forwards to the parent using the given target origin. installDownlink additionally accepts per-call targetOrigin and allowedOrigins for a specific child frame. The defaults are unchanged: with no configuration, messages from any origin are accepted and cross-frame posts still use '*', so existing integrations keep working. Same-window bus messages (event.source === window) are always accepted, as they never cross an origin boundary; only genuine cross-frame messages are filtered. A hard global whitelist is intentionally not enforced because buzz runs on arbitrary integrator domains. Assisted-by: Claude Opus 4.8 Fixes: OX-12527 --- README.md | 68 +++++++++++++++++++++++++++++++++++++---------- buzz.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 127 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b81bf03..258d490 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # scireum BUZZ Die BUZZ Schnittstelle ist eine Nachrichten-orientierte API zum Verbinden von unterschiedlichen Webseiten sowie nativen -Anwendungen wie beispielsweise Apps in Electron, Android oder iOS. Über die Schnittstelle können beispielsweise +Anwendungen wie beispielsweise Apps in Electron, Android oder iOS. Über die Schnittstelle können beispielsweise Konfiguratoren oder Produkt-Auswahlhilfen (z.B. OXOMI) in Webshops integriert werden. Weiterhin kann ein Shop direkt mit einem Handwerker-ERP Daten austauschen. @@ -24,10 +24,10 @@ const catalog = new buzz.Connector({ Anschließend kann geprüft werden, ob die Gegenstelle vorhanden ist und bestimmte Funktionen unterstützt: ```javascript -catalog.queryCapability('item', function() { +catalog.queryCapability('item', function () { // Die Gegenstelle kann Informationen zur Artikeln bieten -> anfragen und anzeigen... - document.querySelectorAll('.item').forEach(function(node) { - catalog.call('item', {}, {item: node.dataset['item']}, function(response) { + document.querySelectorAll('.item').forEach(function (node) { + catalog.call('item', {}, {item: node.dataset['item']}, function (response) { node.querySelector('.price').textContent = response.payload().price; node.querySelector('.availability').textContent = response.payload().availability; }); @@ -36,6 +36,7 @@ catalog.queryCapability('item', function() { ``` Wird aktiv selbst eine fremde Komponente eingebunden, so kann ein eigener Link eingerichtet werden: + ```javascript const client = new buzz.Connector({ name: 'shop', @@ -44,18 +45,52 @@ const client = new buzz.Connector({ ``` Dieser kann dann der Komponente mitgeteilt werden, oder an einen iFrame angedockt werden: + ```javascript new buzz.installDownlink(window.catalogFrame, {link: 'inner'}); ``` Alternativ kann ein iFrame auch direkt an den "Haupt-link" konfiguriert werden, wenn keine Schnittstelle "nach außen" benötigt wird: + ```javascript new buzz.installDownlink(window.catalogFrame, {}); ``` Beispiele finden sich in [shop.html](shop.html) sowie [catalog.html](catalog.html). +## Härtung der Origin-Kommunikation (optional) + +BUZZ wird bewusst auf beliebigen Integrator-Domains eingebunden. Eine statische, global erzwungene Origin-Whitelist +würde deshalb potenziell Integrationen brechen. Standardmäßig akzeptiert BUZZ daher Nachrichten von beliebigen Origins +und sendet Cross-Frame-Nachrichten mit `targetOrigin = '*'` (unverändertes Verhalten). + +Integrationen, die ihre Origins kennen, können die Kommunikation **optional** härten. Same-Window-Bus-Nachrichten +(`event.source === window`) werden dabei immer akzeptiert, da sie keine Origin-Grenze überschreiten; gefiltert +werden ausschließlich echte Cross-Frame-Nachrichten. + +Global (betrifft Connector-Listener und den Uplink zum Parent-Fenster): + +```javascript +buzz.configure({ + allowedOrigins: ['https://shop.example.com'], // nur diese Origins dürfen Cross-Frame-Nachrichten senden + targetOrigin: 'https://shop.example.com' // Ziel-Origin beim Weiterleiten an das Parent-Fenster +}); +``` + +Pro Downlink (betrifft die Kommunikation mit einem konkreten Child-iFrame): + +```javascript +buzz.installDownlink(window.catalogFrame, { + link: 'inner', + targetOrigin: 'https://configurator.example.com', // Ziel-Origin beim Posten an das Child-Frame + allowedOrigins: ['https://configurator.example.com'] // akzeptierte Origins für Nachrichten aus dem Child-Frame +}); +``` + +Werden die Optionen weggelassen, bleibt das bisherige Verhalten vollständig erhalten (kein Breaking Change). +Die Fenster-Identität (`event.source`) wird bei Downlinks unabhängig von `allowedOrigins` weiterhin geprüft. + ## Standard-Nachrichten Um einen sinnvollen Austausch unterschiedlicher Systeme zu ermöglichen, werden im folgenden Nachrichten für gängige @@ -66,15 +101,19 @@ nach Bedarf mi-übermittelt werden. Elemente wie Konfiguratoren oder erweiterte Artikeldatenbanken benötigen Informationen bezüglich Preis und Verfügbarkeit eines Artikels. Hierfür wird die "capability" bzw. der Nachrichten-Type **priceAvailability** verwendet. Um die -Anzeige weiter anzupassen, können Artikelnummer, Type und Kurztext überschrieben werden (um so z.B. eine Herstellernummer +Anzeige weiter anzupassen, können Artikelnummer, Type und Kurztext überschrieben werden (um so z.B. eine +Herstellernummer durch eine eigene Artikelnummer zu ersetzen). **Request** + * version: Versionsnummer der Nachricht. Derzeit immer "1". -* supplierNumber: Gibt die Herstellernummer/Lieferantennummer an, falls bekannt. Falls ein eigener Artikel angefragt wird, kann hier "-" verwendet werden. Falls die Herstellernummer unbekannt ist, kann der Parameter weggelassen werden. +* supplierNumber: Gibt die Herstellernummer/Lieferantennummer an, falls bekannt. Falls ein eigener Artikel angefragt + wird, kann hier "-" verwendet werden. Falls die Herstellernummer unbekannt ist, kann der Parameter weggelassen werden. * itemNumber: Gibt die Artikelnummer an, für die Informationen geliefert werden. **Response** + * itemNumber: Die effektive Artikelnummer die angezeigt werden soll (optional) * model: Die effektive Type die angezeigt werden soll (optional) * shortText: Der effektive Kurztext der angezeigt werden soll (optional) @@ -89,6 +128,7 @@ Neben der reinen Anzeige von Preis- und Verfügbarkeit, können mit **itemData** werden. Die Anfrage ist hierbei gleich wie bei **priceAvailability**. **Response** + * *Felder aus **priceAvailability** Antwort* * previewImageUrl: Url zu einem Vorschaubild (optional) * datasheetUrl: Url zu einem Datenblatt / Produkt-Detailseite (optional) @@ -98,16 +138,16 @@ werden. Die Anfrage ist hierbei gleich wie bei **priceAvailability**. Um Artikel in den Warenkorb zu legen, werden unterschiedliche Capabilities / Nachrichten verwendet: * **addItemToBasket** - * supplierNumber: Lieferantennummer - * itemNumber: Artikelnummer - * quantity: Menge - * unit: Mengeneinheit (optional) - * shortText: Kurzbeschreibung der Position - * supplierName: Name des Herstellers / der Marke - * previewImageUrl: Vorschaubild für den Artikel - + * supplierNumber: Lieferantennummer + * itemNumber: Artikelnummer + * quantity: Menge + * unit: Mengeneinheit (optional) + * shortText: Kurzbeschreibung der Position + * supplierName: Name des Herstellers / der Marke + * previewImageUrl: Vorschaubild für den Artikel ## Lizenz + ``` MIT License diff --git a/buzz.js b/buzz.js index 3a79fa9..b196ba8 100644 --- a/buzz.js +++ b/buzz.js @@ -62,6 +62,33 @@ */ let idCounter = 0; + /** + * Holds the optional, opt-in origin-hardening configuration. + * + * When left at its defaults, the library behaves exactly as before: messages from any origin are accepted, and + * cross-frame messages are posted with a '*' target origin. A hard global whitelist is intentionally NOT enforced + * by default because buzz is often embedded in arbitrary integrator domains - see buzz.configure(). + * + * @type {{allowedOrigins: (string[]|null), targetOrigin: string}} + */ + const securityConfig = { + allowedOrigins: null, + targetOrigin: '*' + }; + + /** + * Determines whether a cross-frame message from the given origin may be processed. + * + * Returns true unless an explicit allowlist has been configured via buzz.configure(). This keeps the default + * behavior unchanged so existing integrations on arbitrary integrator domains keep working. + * + * @param {string} origin the origin of the received message + * @returns {boolean} true if the origin is allowed, false otherwise + */ + function isOriginAllowed(origin) { + return securityConfig.allowedOrigins === null || securityConfig.allowedOrigins.indexOf(origin) !== -1; + } + /** * Generates a unique message ID. * @@ -178,7 +205,10 @@ window.addEventListener('message', function (event) { try { const data = JSON.parse(event.data); - if (isBuzzMessage(data, _me.link) && data.sender !== _me.uid && (!data.receiver || data.receiver === _me.uid)) { + if (isBuzzMessage(data, _me.link) + && data.sender !== _me.uid + && (!data.receiver || data.receiver === _me.uid) + && (event.source === window || isOriginAllowed(event.origin))) { if (_me.options.hasOwnProperty('customMessageCallback')) { _me.options.customMessageCallback(new buzz.Message(_me, data)); } else { @@ -186,7 +216,7 @@ if (callback != null) { try { callback(new buzz.Message(_me, data)); - } catch(error) { + } catch (error) { console.log("BUZZ handler failed to execute!", error); } } @@ -258,6 +288,33 @@ } + /** + * Optionally hardens the cross-frame communication against foreign frames. + * + * This is fully opt-in and backwards compatible: without calling this (or with empty options), buzz keeps its + * previous behavior of accepting messages from any origin and posting cross-frame messages with a '*' target origin. + * A hard global whitelist is intentionally NOT enforced, because buzz/oxomi.js run on arbitrary integrator domains + * and a static whitelist would break legitimate integrations. + * + * Same-window bus messages (event.source === window) are always accepted regardless of the allowlist, as they never + * cross an origin boundary; only genuine cross-frame messages are filtered. + * + * @param {Object} [options] the hardening options + * @param {string[]} [options.allowedOrigins] origins from which cross-frame messages are accepted (uplink + * forwarding and direct cross-frame posts). When omitted, messages from any origin are accepted (default). + * @param {string} [options.targetOrigin] the target origin used when forwarding messages to the parent + * window via the uplink. Defaults to '*'. + */ + buzz.configure = function (options) { + options = options || {}; + if (Array.isArray(options.allowedOrigins)) { + securityConfig.allowedOrigins = options.allowedOrigins; + } + if (typeof options.targetOrigin === 'string') { + securityConfig.targetOrigin = options.targetOrigin; + } + } + /** * Enables the built-in debugger, which logs all messages to the console. */ @@ -287,11 +344,21 @@ * @param {HTMLIFrameElement} childFrame the iFrame to connect * @param {Object} options the options to pass in * @param {string} [options.link] the link to connect to. This can be left empty, to use the default link. + * @param {string} [options.targetOrigin] the target origin used when posting messages to the child frame. + * Defaults to '*' (unchanged behavior) when omitted. + * @param {string[]} [options.allowedOrigins] origins from which messages sent by the child frame are accepted. + * When omitted, messages from the child window are accepted regardless of origin (unchanged behavior). The + * child window identity (event.source) is always verified independently of this option. * @param {Object} extensions a JSON object which will be appended to the payload of each message received from the * childFrame. */ buzz.installDownlink = function (childFrame, options, extensions) { const link = options.link || LINK_NAME_BUZZ_ROOT; + const childTargetOrigin = options.targetOrigin || '*'; + const childAllowedOrigins = Array.isArray(options.allowedOrigins) ? options.allowedOrigins : null; + const isChildOriginAllowed = function (origin) { + return childAllowedOrigins === null || childAllowedOrigins.indexOf(origin) !== -1; + }; console.log('scireum BUZZ - Installing a downlink for bus ' + link + '... ', window, childFrame.contentWindow); window.addEventListener('message', function (event) { if (event.source === window) { @@ -300,14 +367,14 @@ const data = JSON.parse(event.data); if (isBuzzMessage(data, link) && !data.uplink) { data.buzzLink = LINK_NAME_BUZZ_ROOT; - childFrame.contentWindow.postMessage(JSON.stringify(data), '*'); + childFrame.contentWindow.postMessage(JSON.stringify(data), childTargetOrigin); } } catch (ignored) { // Only triggered, if an external message (which is either not a string or // isn't well-formed JSON) is received. In any case, we can discard this error // as it wasn't a BUZZ message anyway and we'd only jam the browser console... } - } else if (childFrame && childFrame.contentWindow && event.source === childFrame.contentWindow) { + } else if (childFrame && childFrame.contentWindow && event.source === childFrame.contentWindow && isChildOriginAllowed(event.origin)) { // Receive messages from child window... try { const data = JSON.parse(event.data); @@ -345,10 +412,10 @@ window.addEventListener('message', function (event) { try { const data = JSON.parse(event.data); - if (event.source !== window.parent) { + if (event.source !== window.parent && (event.source === window || isOriginAllowed(event.origin))) { if (isBuzzMessage(data, LINK_NAME_BUZZ_ROOT)) { data.buzzLink = LINK_NAME_UPLINK; - window.parent.postMessage(JSON.stringify(data), "*"); + window.parent.postMessage(JSON.stringify(data), securityConfig.targetOrigin); } } } catch (ignored) {