You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/01 23:39:00 UTC

(superset) 01/01: fix(embedded): enforce configured allowed domains for postMessage origin

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/embedded-postmessage-origin-enforcement
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 2dbd14c8eaadc9429656fde6968a778a7653bace
Author: Claude Code <no...@anthropic.com>
AuthorDate: Mon Jun 1 16:37:58 2026 -0700

    fix(embedded): enforce configured allowed domains for postMessage origin
    
    The embedded dashboard page validates the origin of incoming postMessage
    events against the dashboard's configured Allowed Domains. The server-rendered
    embedded view exposes the configured domains in its bootstrap payload, and the
    frontend rejects message events whose origin is not in that list.
    
    Enforcement is opt-in by configuration: when the Allowed Domains list is empty
    (the default) any origin is accepted, preserving existing behavior for embeds
    that did not configure domains.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 UPDATING.md                                        |  6 ++
 superset-frontend/src/embedded/index.tsx           | 30 +------
 .../src/embedded/originValidation.test.ts          | 91 ++++++++++++++++++++++
 superset-frontend/src/embedded/originValidation.ts | 68 ++++++++++++++++
 superset-frontend/src/types/bootstrapTypes.ts      |  3 +
 superset/embedded/view.py                          |  4 +
 tests/integration_tests/embedded/test_view.py      | 23 ++++++
 7 files changed, 198 insertions(+), 27 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index ea73f6adf63..a5dc6caddda 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,12 @@ assists people when migrating to a new version.
 
 ## Next
 
+### Embedded dashboards enforce configured Allowed Domains for postMessage
+
+The embedded dashboard page now validates the origin of incoming `postMessage` events against the dashboard's configured **Allowed Domains**. The server-rendered embedded page exposes the configured domains in its bootstrap payload, and the frontend rejects message events whose origin is not in that list.
+
+Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
+
 ### Dataset import validates catalog against the target connection
 
 Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
index 1a4a9eebcc9..b0a09f81645 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -48,6 +48,7 @@ import {
 } from './EmbeddedContextProviders';
 import { embeddedApi } from './api';
 import { getDataMaskChangeTrigger } from './utils';
+import { validateMessageEvent } from './originValidation';
 
 setupPlugins();
 setupCodeOverrides({ embedded: true });
@@ -125,8 +126,6 @@ const EmbeddedApp = () => (
 
 const appMountPoint = document.getElementById('app')!;
 
-const MESSAGE_TYPE = '__embedded_comms__';
-
 function showFailureMessage(message: string) {
   appMountPoint.innerHTML = message;
 }
@@ -139,17 +138,6 @@ if (!window.parent || window.parent === window) {
   );
 }
 
-// if the page is embedded in an origin that hasn't
-// been authorized by the curator, we forbid access entirely.
-// todo: check the referrer on the route serving this page instead
-// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
-// const parentOrigin = new URL(document.referrer).origin;
-// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
-//   throw new Error(
-//     `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
-//   );
-// }
-
 let displayedUnauthorizedToast = false;
 let root: Root | null = null;
 let started = false;
@@ -225,21 +213,9 @@ function setupGuestClient(guestToken: string) {
   });
 }
 
-function validateMessageEvent(event: MessageEvent) {
-  // if (!ALLOW_ORIGINS.includes(event.origin)) {
-  //   throw new Error('Message origin is not in the allowed list');
-  // }
-
-  if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
-    throw new Error(`Message type does not match type used for embedded comms`);
-  }
-}
-
 window.addEventListener('message', function embeddedPageInitializer(event) {
-  try {
-    validateMessageEvent(event);
-  } catch (err) {
-    log('ignoring message unrelated to embedded comms', err, event);
+  if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
+    log('ignoring message unrelated to embedded comms', event);
     return;
   }
 
diff --git a/superset-frontend/src/embedded/originValidation.test.ts b/superset-frontend/src/embedded/originValidation.test.ts
new file mode 100644
index 00000000000..4a679b06261
--- /dev/null
+++ b/superset-frontend/src/embedded/originValidation.test.ts
@@ -0,0 +1,91 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  MESSAGE_TYPE,
+  isMessageOriginAllowed,
+  validateMessageEvent,
+} from './originValidation';
+
+const makeEvent = (origin: string, data: unknown): MessageEvent =>
+  ({ origin, data }) as MessageEvent;
+
+const validData = { type: MESSAGE_TYPE, handshake: 'port transfer' };
+
+test('isMessageOriginAllowed allows any origin when the list is undefined', () => {
+  expect(isMessageOriginAllowed('https://anywhere.example.com')).toBe(true);
+});
+
+test('isMessageOriginAllowed allows any origin when the list is empty', () => {
+  expect(isMessageOriginAllowed('https://anywhere.example.com', [])).toBe(true);
+});
+
+test('isMessageOriginAllowed allows an origin that is in the list', () => {
+  expect(
+    isMessageOriginAllowed('https://allowed.example.com', [
+      'https://allowed.example.com',
+    ]),
+  ).toBe(true);
+});
+
+test('isMessageOriginAllowed rejects an origin that is not in the list and warns', () => {
+  const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+  expect(
+    isMessageOriginAllowed('https://evil.example.com', [
+      'https://allowed.example.com',
+    ]),
+  ).toBe(false);
+  expect(warn).toHaveBeenCalledTimes(1);
+  warn.mockRestore();
+});
+
+test('validateMessageEvent accepts a valid embedded message from any origin when unrestricted', () => {
+  const event = makeEvent('https://anywhere.example.com', validData);
+  expect(validateMessageEvent(event)).toBe(true);
+  expect(validateMessageEvent(event, [])).toBe(true);
+});
+
+test('validateMessageEvent accepts a valid embedded message from a listed origin', () => {
+  const event = makeEvent('https://allowed.example.com', validData);
+  expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
+    true,
+  );
+});
+
+test('validateMessageEvent rejects a message from an origin not in a non-empty list', () => {
+  const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+  const event = makeEvent('https://evil.example.com', validData);
+  expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
+    false,
+  );
+  warn.mockRestore();
+});
+
+test('validateMessageEvent rejects a message whose data type does not match', () => {
+  const event = makeEvent('https://allowed.example.com', {
+    type: 'something-else',
+  });
+  expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
+    false,
+  );
+});
+
+test('validateMessageEvent rejects a message whose data is not an object', () => {
+  const event = makeEvent('https://allowed.example.com', 'not-an-object');
+  expect(validateMessageEvent(event)).toBe(false);
+});
diff --git a/superset-frontend/src/embedded/originValidation.ts b/superset-frontend/src/embedded/originValidation.ts
new file mode 100644
index 00000000000..c48aa8d1ee1
--- /dev/null
+++ b/superset-frontend/src/embedded/originValidation.ts
@@ -0,0 +1,68 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The message type used for the embedded comms handshake. Must stay in sync
+// with the parent SDK's handshake messages.
+export const MESSAGE_TYPE = '__embedded_comms__';
+
+/**
+ * Validates the origin of an incoming postMessage event against the dashboard's
+ * configured allowed domains.
+ *
+ * Enforcement is opt-in by configuration: if the allowed-domains list is empty
+ * or undefined, any origin is accepted (no restriction), which preserves the
+ * historical behavior for embeds that did not configure domains. When the list
+ * is non-empty, only origins present in the list are accepted.
+ */
+export function isMessageOriginAllowed(
+  origin: string,
+  allowedDomains?: string[],
+): boolean {
+  if (!allowedDomains || allowedDomains.length === 0) {
+    return true;
+  }
+  if (allowedDomains.includes(origin)) {
+    return true;
+  }
+  // eslint-disable-next-line no-console
+  console.warn(
+    `[superset] ignoring embedded message from origin "${origin}" which is not in the list of allowed domains`,
+  );
+  return false;
+}
+
+/**
+ * Validates that an incoming message is intended for embedded comms and that it
+ * originates from an allowed domain. Returns `true` when the message should be
+ * processed, `false` otherwise.
+ */
+export function validateMessageEvent(
+  event: MessageEvent,
+  allowedDomains?: string[],
+): boolean {
+  if (!isMessageOriginAllowed(event.origin, allowedDomains)) {
+    return false;
+  }
+
+  if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
+    return false;
+  }
+
+  return true;
+}
diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts
index 610767c389a..4b8e646fbbf 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -178,6 +178,9 @@ export interface BootstrapData {
   config?: any;
   embedded?: {
     dashboard_id: string;
+    // Domains allowed to embed this dashboard. An empty/undefined list means
+    // any domain is allowed (no restriction).
+    allowed_domains?: string[];
   };
   requested_query?: JsonObject;
 }
diff --git a/superset/embedded/view.py b/superset/embedded/view.py
index 6e5419286d3..a48b086336b 100644
--- a/superset/embedded/view.py
+++ b/superset/embedded/view.py
@@ -83,6 +83,10 @@ class EmbeddedView(BaseSupersetView):
             "common": common_bootstrap_payload(),
             "embedded": {
                 "dashboard_id": embedded.dashboard_id,
+                # The list of domains allowed to embed this dashboard. An empty
+                # list means any domain is allowed (no restriction). The frontend
+                # uses this to validate the origin of incoming postMessage events.
+                "allowed_domains": embedded.allowed_domains,
             },
         }
 
diff --git a/tests/integration_tests/embedded/test_view.py b/tests/integration_tests/embedded/test_view.py
index f4d5ae69255..c63355e91c7 100644
--- a/tests/integration_tests/embedded/test_view.py
+++ b/tests/integration_tests/embedded/test_view.py
@@ -48,6 +48,29 @@ def test_get_embedded_dashboard(client: FlaskClient[Any]):  # noqa: F811
     uri = f"embedded/{embedded.uuid}"
     response = client.get(uri)
     assert response.status_code == 200
+    # The bootstrap payload exposes the (empty) allowed-domains list so the
+    # frontend can validate postMessage origins.
+    data = response.data.decode("utf-8")
+    assert "allowed_domains" in data
+
+
+@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+@mock.patch.dict(
+    "superset.extensions.feature_flag_manager._feature_flags",
+    EMBEDDED_SUPERSET=True,
+)
+def test_get_embedded_dashboard_bootstrap_includes_allowed_domains(
+    client: FlaskClient[Any],  # noqa: F811
+):
+    dash = db.session.query(Dashboard).filter_by(slug="births").first()
+    embedded = EmbeddedDashboardDAO.upsert(dash, ["https://allowed.example.com"])
+    db.session.flush()
+    uri = f"embedded/{embedded.uuid}"
+    response = client.get(uri, headers={"Referer": "https://allowed.example.com"})
+    assert response.status_code == 200
+    data = response.data.decode("utf-8")
+    assert "allowed_domains" in data
+    assert "https://allowed.example.com" in data
 
 
 @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")