fix(embedded): enforce configured allowed domains for postMessage origin (#40629)

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-06-02 22:58:30 -07:00
committed by GitHub
parent df21fe6571
commit fa41769a08
7 changed files with 252 additions and 27 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
},
}

View File

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