mirror of
https://github.com/apache/superset.git
synced 2026-07-18 20:55:47 +00:00
fix(embedded): enforce configured allowed domains for postMessage origin (#40629)
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
112
superset-frontend/src/embedded/originValidation.test.ts
Normal file
112
superset-frontend/src/embedded/originValidation.test.ts
Normal 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);
|
||||
});
|
||||
91
superset-frontend/src/embedded/originValidation.ts
Normal file
91
superset-frontend/src/embedded/originValidation.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user