diff --git a/UPDATING.md b/UPDATING.md index f5ee4f80c34..e1012be14d1 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -28,6 +28,12 @@ assists people when migrating to a new version. YDB SQL parsing now relies on the dedicated [`ydb-sqlglot-plugin`](https://pypi.org/project/ydb-sqlglot-plugin/) dialect, which registers itself with sqlglot automatically. YDB users must install this plugin (e.g., via `pip install "apache-superset[ydb]"`) to avoid a `ValueError` when Superset parses YDB queries. +### 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..2ccdf622f90 --- /dev/null +++ b/superset-frontend/src/embedded/originValidation.test.ts @@ -0,0 +1,112 @@ +/** + * 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 matches a listed domain with a trailing slash', () => { + expect( + isMessageOriginAllowed('https://allowed.example.com', [ + 'https://allowed.example.com/', + ]), + ).toBe(true); +}); + +test('isMessageOriginAllowed matches a listed domain that includes a path', () => { + expect( + isMessageOriginAllowed('https://allowed.example.com', [ + 'https://allowed.example.com/embed', + ]), + ).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); +}); + +test('validateMessageEvent rejects a message whose data is null', () => { + const event = makeEvent('https://allowed.example.com', null); + 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..022c692f422 --- /dev/null +++ b/superset-frontend/src/embedded/originValidation.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. + */ + +// 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__'; + +/** + * Normalizes an allowed-domain entry to its origin (`scheme://host[:port]`), + * matching the comparison the backend performs via `flask_wtf.csrf.same_origin`. + * + * A browser-provided `event.origin` never carries a path or trailing slash, but + * configured allowed domains may (e.g. `https://example.com/` or + * `https://app.example.com/embed`). Reducing each entry to its origin keeps the + * frontend and backend in agreement about what an "allowed domain" is. Entries + * that cannot be parsed as a URL fall back to the raw value so an exact match is + * still possible. + */ +function normalizeToOrigin(domain: string): string { + try { + return new URL(domain).origin; + } catch { + return domain; + } +} + +/** + * 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.some(domain => normalizeToOrigin(domain) === 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 === null || + 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..800be6de9a4 100644 --- a/tests/integration_tests/embedded/test_view.py +++ b/tests/integration_tests/embedded/test_view.py @@ -16,6 +16,8 @@ # under the License. from __future__ import annotations +import html +import re from typing import TYPE_CHECKING from unittest import mock @@ -24,6 +26,7 @@ import pytest from superset import db from superset.daos.dashboard import EmbeddedDashboardDAO from superset.models.dashboard import Dashboard +from superset.utils import json from tests.integration_tests.fixtures.birth_names_dashboard import ( load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_data, # noqa: F401 @@ -36,6 +39,14 @@ if TYPE_CHECKING: from flask.testing import FlaskClient +def _extract_bootstrap_data(response_data: bytes) -> dict[str, Any]: + """Parse the JSON bootstrap payload embedded in the SPA template.""" + html_body = response_data.decode("utf-8") + match = re.search(r'data-bootstrap="([^"]*)"', html_body) + assert match is not None, "bootstrap payload not found in response" + return json.loads(html.unescape(match.group(1))) + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @mock.patch.dict( "superset.extensions.feature_flag_manager._feature_flags", @@ -48,6 +59,28 @@ 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. + bootstrap = _extract_bootstrap_data(response.data) + assert bootstrap["embedded"]["allowed_domains"] == [] + + +@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 + bootstrap = _extract_bootstrap_data(response.data) + assert bootstrap["embedded"]["allowed_domains"] == ["https://allowed.example.com"] @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")