Skip to content

Commit fa41769

Browse files
rusackasclaude
andauthored
fix(embedded): enforce configured allowed domains for postMessage origin (#40629)
Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent df21fe6 commit fa41769

7 files changed

Lines changed: 252 additions & 27 deletions

File tree

UPDATING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ assists people when migrating to a new version.
2828

2929
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.
3030

31+
### Embedded dashboards enforce configured Allowed Domains for postMessage
32+
33+
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.
34+
35+
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.
36+
3137
### Dataset import validates catalog against the target connection
3238

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

superset-frontend/src/embedded/index.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
} from './EmbeddedContextProviders';
4949
import { embeddedApi } from './api';
5050
import { getDataMaskChangeTrigger } from './utils';
51+
import { validateMessageEvent } from './originValidation';
5152

5253
setupPlugins();
5354
setupCodeOverrides({ embedded: true });
@@ -125,8 +126,6 @@ const EmbeddedApp = () => (
125126

126127
const appMountPoint = document.getElementById('app')!;
127128

128-
const MESSAGE_TYPE = '__embedded_comms__';
129-
130129
function showFailureMessage(message: string) {
131130
appMountPoint.innerHTML = message;
132131
}
@@ -139,17 +138,6 @@ if (!window.parent || window.parent === window) {
139138
);
140139
}
141140

142-
// if the page is embedded in an origin that hasn't
143-
// been authorized by the curator, we forbid access entirely.
144-
// todo: check the referrer on the route serving this page instead
145-
// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
146-
// const parentOrigin = new URL(document.referrer).origin;
147-
// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
148-
// throw new Error(
149-
// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
150-
// );
151-
// }
152-
153141
let displayedUnauthorizedToast = false;
154142
let root: Root | null = null;
155143
let started = false;
@@ -225,21 +213,9 @@ function setupGuestClient(guestToken: string) {
225213
});
226214
}
227215

228-
function validateMessageEvent(event: MessageEvent) {
229-
// if (!ALLOW_ORIGINS.includes(event.origin)) {
230-
// throw new Error('Message origin is not in the allowed list');
231-
// }
232-
233-
if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
234-
throw new Error(`Message type does not match type used for embedded comms`);
235-
}
236-
}
237-
238216
window.addEventListener('message', function embeddedPageInitializer(event) {
239-
try {
240-
validateMessageEvent(event);
241-
} catch (err) {
242-
log('ignoring message unrelated to embedded comms', err, event);
217+
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
218+
log('ignoring message unrelated to embedded comms', event);
243219
return;
244220
}
245221

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import {
20+
MESSAGE_TYPE,
21+
isMessageOriginAllowed,
22+
validateMessageEvent,
23+
} from './originValidation';
24+
25+
const makeEvent = (origin: string, data: unknown): MessageEvent =>
26+
({ origin, data }) as MessageEvent;
27+
28+
const validData = { type: MESSAGE_TYPE, handshake: 'port transfer' };
29+
30+
test('isMessageOriginAllowed allows any origin when the list is undefined', () => {
31+
expect(isMessageOriginAllowed('https://anywhere.example.com')).toBe(true);
32+
});
33+
34+
test('isMessageOriginAllowed allows any origin when the list is empty', () => {
35+
expect(isMessageOriginAllowed('https://anywhere.example.com', [])).toBe(true);
36+
});
37+
38+
test('isMessageOriginAllowed allows an origin that is in the list', () => {
39+
expect(
40+
isMessageOriginAllowed('https://allowed.example.com', [
41+
'https://allowed.example.com',
42+
]),
43+
).toBe(true);
44+
});
45+
46+
test('isMessageOriginAllowed matches a listed domain with a trailing slash', () => {
47+
expect(
48+
isMessageOriginAllowed('https://allowed.example.com', [
49+
'https://allowed.example.com/',
50+
]),
51+
).toBe(true);
52+
});
53+
54+
test('isMessageOriginAllowed matches a listed domain that includes a path', () => {
55+
expect(
56+
isMessageOriginAllowed('https://allowed.example.com', [
57+
'https://allowed.example.com/embed',
58+
]),
59+
).toBe(true);
60+
});
61+
62+
test('isMessageOriginAllowed rejects an origin that is not in the list and warns', () => {
63+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
64+
expect(
65+
isMessageOriginAllowed('https://evil.example.com', [
66+
'https://allowed.example.com',
67+
]),
68+
).toBe(false);
69+
expect(warn).toHaveBeenCalledTimes(1);
70+
warn.mockRestore();
71+
});
72+
73+
test('validateMessageEvent accepts a valid embedded message from any origin when unrestricted', () => {
74+
const event = makeEvent('https://anywhere.example.com', validData);
75+
expect(validateMessageEvent(event)).toBe(true);
76+
expect(validateMessageEvent(event, [])).toBe(true);
77+
});
78+
79+
test('validateMessageEvent accepts a valid embedded message from a listed origin', () => {
80+
const event = makeEvent('https://allowed.example.com', validData);
81+
expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
82+
true,
83+
);
84+
});
85+
86+
test('validateMessageEvent rejects a message from an origin not in a non-empty list', () => {
87+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
88+
const event = makeEvent('https://evil.example.com', validData);
89+
expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
90+
false,
91+
);
92+
warn.mockRestore();
93+
});
94+
95+
test('validateMessageEvent rejects a message whose data type does not match', () => {
96+
const event = makeEvent('https://allowed.example.com', {
97+
type: 'something-else',
98+
});
99+
expect(validateMessageEvent(event, ['https://allowed.example.com'])).toBe(
100+
false,
101+
);
102+
});
103+
104+
test('validateMessageEvent rejects a message whose data is not an object', () => {
105+
const event = makeEvent('https://allowed.example.com', 'not-an-object');
106+
expect(validateMessageEvent(event)).toBe(false);
107+
});
108+
109+
test('validateMessageEvent rejects a message whose data is null', () => {
110+
const event = makeEvent('https://allowed.example.com', null);
111+
expect(validateMessageEvent(event)).toBe(false);
112+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
// The message type used for the embedded comms handshake. Must stay in sync
21+
// with the parent SDK's handshake messages.
22+
export const MESSAGE_TYPE = '__embedded_comms__';
23+
24+
/**
25+
* Normalizes an allowed-domain entry to its origin (`scheme://host[:port]`),
26+
* matching the comparison the backend performs via `flask_wtf.csrf.same_origin`.
27+
*
28+
* A browser-provided `event.origin` never carries a path or trailing slash, but
29+
* configured allowed domains may (e.g. `https://example.com/` or
30+
* `https://app.example.com/embed`). Reducing each entry to its origin keeps the
31+
* frontend and backend in agreement about what an "allowed domain" is. Entries
32+
* that cannot be parsed as a URL fall back to the raw value so an exact match is
33+
* still possible.
34+
*/
35+
function normalizeToOrigin(domain: string): string {
36+
try {
37+
return new URL(domain).origin;
38+
} catch {
39+
return domain;
40+
}
41+
}
42+
43+
/**
44+
* Validates the origin of an incoming postMessage event against the dashboard's
45+
* configured allowed domains.
46+
*
47+
* Enforcement is opt-in by configuration: if the allowed-domains list is empty
48+
* or undefined, any origin is accepted (no restriction), which preserves the
49+
* historical behavior for embeds that did not configure domains. When the list
50+
* is non-empty, only origins present in the list are accepted.
51+
*/
52+
export function isMessageOriginAllowed(
53+
origin: string,
54+
allowedDomains?: string[],
55+
): boolean {
56+
if (!allowedDomains || allowedDomains.length === 0) {
57+
return true;
58+
}
59+
if (allowedDomains.some(domain => normalizeToOrigin(domain) === origin)) {
60+
return true;
61+
}
62+
// eslint-disable-next-line no-console
63+
console.warn(
64+
`[superset] ignoring embedded message from origin "${origin}" which is not in the list of allowed domains`,
65+
);
66+
return false;
67+
}
68+
69+
/**
70+
* Validates that an incoming message is intended for embedded comms and that it
71+
* originates from an allowed domain. Returns `true` when the message should be
72+
* processed, `false` otherwise.
73+
*/
74+
export function validateMessageEvent(
75+
event: MessageEvent,
76+
allowedDomains?: string[],
77+
): boolean {
78+
if (!isMessageOriginAllowed(event.origin, allowedDomains)) {
79+
return false;
80+
}
81+
82+
if (
83+
typeof event.data !== 'object' ||
84+
event.data === null ||
85+
event.data.type !== MESSAGE_TYPE
86+
) {
87+
return false;
88+
}
89+
90+
return true;
91+
}

superset-frontend/src/types/bootstrapTypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ export interface BootstrapData {
178178
config?: any;
179179
embedded?: {
180180
dashboard_id: string;
181+
// Domains allowed to embed this dashboard. An empty/undefined list means
182+
// any domain is allowed (no restriction).
183+
allowed_domains?: string[];
181184
};
182185
requested_query?: JsonObject;
183186
}

superset/embedded/view.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ def embedded(
8383
"common": common_bootstrap_payload(),
8484
"embedded": {
8585
"dashboard_id": embedded.dashboard_id,
86+
# The list of domains allowed to embed this dashboard. An empty
87+
# list means any domain is allowed (no restriction). The frontend
88+
# uses this to validate the origin of incoming postMessage events.
89+
"allowed_domains": embedded.allowed_domains,
8690
},
8791
}
8892

tests/integration_tests/embedded/test_view.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
import html
20+
import re
1921
from typing import TYPE_CHECKING
2022
from unittest import mock
2123

@@ -24,6 +26,7 @@
2426
from superset import db
2527
from superset.daos.dashboard import EmbeddedDashboardDAO
2628
from superset.models.dashboard import Dashboard
29+
from superset.utils import json
2730
from tests.integration_tests.fixtures.birth_names_dashboard import (
2831
load_birth_names_dashboard_with_slices, # noqa: F401
2932
load_birth_names_data, # noqa: F401
@@ -36,6 +39,14 @@
3639
from flask.testing import FlaskClient
3740

3841

42+
def _extract_bootstrap_data(response_data: bytes) -> dict[str, Any]:
43+
"""Parse the JSON bootstrap payload embedded in the SPA template."""
44+
html_body = response_data.decode("utf-8")
45+
match = re.search(r'data-bootstrap="([^"]*)"', html_body)
46+
assert match is not None, "bootstrap payload not found in response"
47+
return json.loads(html.unescape(match.group(1)))
48+
49+
3950
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
4051
@mock.patch.dict(
4152
"superset.extensions.feature_flag_manager._feature_flags",
@@ -48,6 +59,28 @@ def test_get_embedded_dashboard(client: FlaskClient[Any]): # noqa: F811
4859
uri = f"embedded/{embedded.uuid}"
4960
response = client.get(uri)
5061
assert response.status_code == 200
62+
# The bootstrap payload exposes the (empty) allowed-domains list so the
63+
# frontend can validate postMessage origins.
64+
bootstrap = _extract_bootstrap_data(response.data)
65+
assert bootstrap["embedded"]["allowed_domains"] == []
66+
67+
68+
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
69+
@mock.patch.dict(
70+
"superset.extensions.feature_flag_manager._feature_flags",
71+
EMBEDDED_SUPERSET=True,
72+
)
73+
def test_get_embedded_dashboard_bootstrap_includes_allowed_domains(
74+
client: FlaskClient[Any], # noqa: F811
75+
):
76+
dash = db.session.query(Dashboard).filter_by(slug="births").first()
77+
embedded = EmbeddedDashboardDAO.upsert(dash, ["https://allowed.example.com"])
78+
db.session.flush()
79+
uri = f"embedded/{embedded.uuid}"
80+
response = client.get(uri, headers={"Referer": "https://allowed.example.com"})
81+
assert response.status_code == 200
82+
bootstrap = _extract_bootstrap_data(response.data)
83+
assert bootstrap["embedded"]["allowed_domains"] == ["https://allowed.example.com"]
5184

5285

5386
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")

0 commit comments

Comments
 (0)