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")