Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Code
97b6465ece fix(embedded-sdk): validate supersetDomain and surface relaxing sandbox tokens
Builds on the dashboard id validation in this PR with two more
defense-in-depth input checks on embedDashboard params:

- supersetDomain is validated as a parseable absolute URL (it must carry a
  protocol), and the postMessage targetOrigin now uses the normalized
  `new URL(supersetDomain).origin` rather than the raw domain. Sub-path
  deployments keep working because the iframe src still uses the full
  domain; only the targetOrigin is normalized to a clean origin.
- iframeSandboxExtras tokens that relax the iframe's isolation are still
  honored (the option is an intentional escape hatch) but are now logged
  via console.warn so they aren't enabled unintentionally.

Adds unit tests for validateSupersetDomain and findUnsafeSandboxExtras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:34:06 -07:00
Claude Code
4f2fe42044 fix(embedded-sdk): validate embedded dashboard id format before use
Add defense-in-depth format validation for the embedded dashboard id
before it is interpolated into the iframe URL. The id is a
Superset-generated UUID (hexadecimal characters and hyphens), so a new
exported `validateEmbeddedDashboardId` helper rejects any value that does
not match that format.

This is a minor behavior change: a malformed id now throws instead of
being used as-is. A unit test file covers the helper, and a jest config
is added so the switchboard ES module is transformed during testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:25:54 -07:00
3 changed files with 173 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
/*
* 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.
*/
module.exports = {
// @superset-ui/switchboard ships ES module syntax, so it must be
// transformed by babel rather than ignored as a node_modules dependency.
transformIgnorePatterns: ["/node_modules/(?!@superset-ui/switchboard)"],
};

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.
*/
import {
validateEmbeddedDashboardId,
validateSupersetDomain,
findUnsafeSandboxExtras,
} from "./index";
describe("validateEmbeddedDashboardId", () => {
it("accepts a canonical uuid", () => {
expect(() =>
validateEmbeddedDashboardId("f4787a4f-2541-4f8a-9b5e-1e2d3c4b5a6f")
).not.toThrow();
});
it("accepts a simple hexadecimal id", () => {
expect(() => validateEmbeddedDashboardId("abc123")).not.toThrow();
});
it.each([
["../../evil"],
["a/b"],
["x?foo=bar"],
["x#frag"],
["a@b.com"],
["foo bar"],
["http://evil.com"],
[""],
["%2e%2e"],
])("rejects an id with an unexpected format: %p", (id) => {
expect(() => validateEmbeddedDashboardId(id)).toThrow();
});
});
describe("validateSupersetDomain", () => {
it.each([
["https://superset.example.com"],
["http://localhost:8088"],
// sub-path deployments are valid; the origin is what matters downstream
["https://example.com/superset"],
])("accepts a valid absolute URL: %p", (domain) => {
expect(() => validateSupersetDomain(domain)).not.toThrow();
});
it.each([
["superset.example.com"], // missing protocol
["not a url"],
[""],
["/relative/path"],
])("rejects a malformed domain: %p", (domain) => {
expect(() => validateSupersetDomain(domain)).toThrow(
"Invalid supersetDomain format"
);
});
});
describe("findUnsafeSandboxExtras", () => {
it("returns the tokens that relax iframe isolation", () => {
expect(
findUnsafeSandboxExtras([
"allow-forms",
"allow-top-navigation",
"allow-popups",
"allow-top-navigation-by-user-activation",
])
).toEqual(["allow-top-navigation", "allow-top-navigation-by-user-activation"]);
});
it("returns an empty array when all tokens are safe", () => {
expect(
findUnsafeSandboxExtras(["allow-forms", "allow-popups", "allow-downloads"])
).toEqual([]);
});
});

View File

@@ -50,7 +50,9 @@ export type UiConfigType = {
};
export type EmbedDashboardParams = {
/** The id provided by the embed configuration UI in Superset */
/** The id provided by the embed configuration UI in Superset.
* This is the UUID generated by Superset's embed configuration and is
* expected to contain only hexadecimal characters and hyphens. */
id: string;
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
supersetDomain: string;
@@ -112,6 +114,48 @@ export type EmbeddedDashboard = {
setThemeMode: (mode: ThemeMode) => void;
};
/**
* Validates that an embedded dashboard id has the expected format
* (the UUID produced by Superset's embed configuration). Throws on
* anything containing characters that are not part of that format.
*/
export function validateEmbeddedDashboardId(id: string): void {
if (typeof id !== 'string' || !/^[a-f0-9-]+$/i.test(id)) {
throw new Error('Invalid dashboard id format');
}
}
/**
* Validates that supersetDomain is a parseable absolute URL (it must include
* a protocol, e.g. https://superset.example.com). Throws otherwise. The
* domain's origin is what gets used as the postMessage targetOrigin, so it
* has to resolve to a well-formed origin.
*/
export function validateSupersetDomain(supersetDomain: string): void {
try {
// eslint-disable-next-line no-new
new URL(supersetDomain);
} catch {
throw new Error('Invalid supersetDomain format');
}
}
// Sandbox tokens that materially relax the iframe's isolation (for example,
// letting the embedded frame navigate the top-level page). They remain
// supported via iframeSandboxExtras for callers that genuinely need them.
const UNSAFE_SANDBOX_EXTRAS = [
'allow-top-navigation',
'allow-top-navigation-by-user-activation',
];
/**
* Returns any caller-provided sandbox tokens that relax the iframe's
* isolation, so they can be surfaced and not enabled unintentionally.
*/
export function findUnsafeSandboxExtras(extras: string[]): string[] {
return extras.filter(token => UNSAFE_SANDBOX_EXTRAS.includes(token));
}
/**
* Embeds a Superset dashboard into the page using an iframe.
*/
@@ -128,6 +172,8 @@ export async function embedDashboard({
referrerPolicy,
resolvePermalinkUrl,
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
validateEmbeddedDashboardId(id);
function log(...info: unknown[]) {
if (debug) {
console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info);
@@ -139,6 +185,7 @@ export async function embedDashboard({
if (supersetDomain.endsWith('/')) {
supersetDomain = supersetDomain.slice(0, -1);
}
validateSupersetDomain(supersetDomain);
function calculateConfig() {
let configNumber = 0;
@@ -195,6 +242,13 @@ export async function embedDashboard({
iframe.sandbox.add('allow-forms'); // for forms to submit
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
// additional sandbox props
const unsafeSandboxExtras = findUnsafeSandboxExtras(iframeSandboxExtras);
if (unsafeSandboxExtras.length > 0) {
console.warn(
'[superset-embedded-sdk] iframeSandboxExtras includes tokens that ' +
`relax the iframe's isolation: ${unsafeSandboxExtras.join(', ')}`,
);
}
iframeSandboxExtras.forEach((key: string) => {
iframe.sandbox.add(key);
});
@@ -216,7 +270,9 @@ export async function embedDashboard({
// we know the content window isn't null because we are in the load event handler.
iframe.contentWindow!.postMessage(
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
supersetDomain,
// Use the normalized origin (not the raw domain, which may carry a
// sub-path for sub-path deployments) as the postMessage targetOrigin.
new URL(supersetDomain).origin,
[theirPort],
);
log('sent message channel to the iframe');