This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c45fc6a31a2 feat(embedded): diagnose guest-token header size budgets
(#43944)
c45fc6a31a2 is described below
commit c45fc6a31a2e178ce3071ea69b9869d8f75e6b64
Author: Mafi <[email protected]>
AuthorDate: Wed Sep 9 04:55:04 2026 +1000
feat(embedded): diagnose guest-token header size budgets (#43944)
Co-authored-by: Matt Fitzgerald <[email protected]>
Co-authored-by: Amin Ghadersohi <[email protected]>
Co-authored-by: Joe Li <[email protected]>
---
docs/docs/using-superset/embedding.mdx | 48 ++++++
.../src/embedded/guestTokenDiagnostics.test.ts | 101 +++++++++++
.../src/embedded/guestTokenDiagnostics.ts | 71 ++++++++
superset-frontend/src/embedded/index.test.tsx | 191 ++++++++++++++++-----
superset-frontend/src/embedded/index.tsx | 46 +++--
superset-frontend/src/types/bootstrapTypes.ts | 5 +-
superset/config.py | 3 +
superset/embedded/view.py | 7 +-
superset/security/api.py | 24 ++-
superset/security/guest_token.py | 19 ++
tests/integration_tests/embedded/test_view.py | 17 +-
.../unit_tests/security/guest_token_audit_test.py | 87 ++++++++++
12 files changed, 550 insertions(+), 69 deletions(-)
diff --git a/docs/docs/using-superset/embedding.mdx
b/docs/docs/using-superset/embedding.mdx
index 611728ddc33..a5db74e7c91 100644
--- a/docs/docs/using-superset/embedding.mdx
+++ b/docs/docs/using-superset/embedding.mdx
@@ -145,3 +145,51 @@ The following URL parameters can be passed through the
`urlParams` option in `da
- **Row-level security** — pass `rls` rules in the guest token request to
restrict which rows are visible to the embedded user.
- **Allowed domains** — restrict which host origins can embed a dashboard by
setting **Allowed Domains** per-dashboard in the _Embed_ settings modal.
Superset checks the request's `Referer` header against this list before serving
the embedded view; an empty list allows any origin, so configure this
explicitly for production.
- **Redacted errors** — API responses to a guest token report a generic `An
error occurred while fetching the data.` instead of the underlying error, since
engine errors quote catalog, schema, table and column names. Errors Superset
raises itself — access denials, timeouts, payload validation — keep their
message, and the full error is always available in the server logs.
+
+
+## Guest-token request-header size diagnostics
+
+A successful guest-token mint does not guarantee the token can pass through
your
+deployment's proxies. Limits apply to the **encoded JWT bytes plus header
+overhead**, not the number of RLS rules or identifiers. A proxy can reject the
+subsequent authentication request before it reaches Superset, including an HTTP
+400 HTML response instead of JSON. A 400 alone does not establish a size
problem.
+
+Operators can set a deployment-specific diagnostic budget in
`superset_config.py`:
+
+```python
+# Example only: choose a budget for your complete proxy path.
+GUEST_TOKEN_HEADER_MAX_BYTES = 16 * 1024
+```
+
+The default is `None` (no budget warnings). Positive integer budgets count
UTF-8
+bytes of `GUEST_TOKEN_HEADER_NAME`, `: `, the encoded token, and `\r\n`
+(four framing bytes). Only sizes **strictly greater** than the budget warn;
+equality does not. This is consistent diagnostic accounting, not a prediction
of
+every proxy's wire-level accounting, HTTP/2 compression, or total-header
limits.
+Leave a safety margin and validate your actual deployment, including custom
+header names. Zero, negative, non-integral, or non-numeric values (including
strings and
+booleans) disable budget warnings, as do values above JavaScript's maximum safe
+integer (2^53 − 1). Whole-number floats are accepted. Convert
environment-variable
+strings to integers in deployment configuration to enable the budget.
+
+Issuance audit metadata includes `token_bytes`, `header_bytes`,
+`header_budget_bytes`, and `header_budget_exceeded`. Issuance remains HTTP 200
+with the same token and response shape. The embedded bootstrap exposes the
budget
+and configured header name; reload the iframe after changing deployment config.
+The embedded client measures initial and refreshed tokens and warns in the
+developer console with sizes only. Initial authentication failures get a
targeted
+suggestion only when the request's token exceeds the budget and the failure has
+no status or HTTP 400/431/494; other statuses and ambiguous in-flight
+refreshes use the generic error. Refresh warnings do not restart
authentication.
+These diagnostics do not record JWTs, decoded claims, RLS SQL, or request
headers.
+
+[AWS Application Load Balancer
quotas](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html)
+list a non-adjustable 16 K single-header limit. Increasing a Superset
diagnostic
+budget does not increase that limit or add large-token support.
+
+To reduce payload size, replace large inline RLS ID lists with a compact
+entitlements-table subquery where supported by your database. Keep the same
+tenant/user restrictions, derive identity from your trusted token-issuing
+backend, and verify equivalent row access and query performance before rollout.
+Do not remove RLS or broaden entitlements to make a token smaller.
diff --git a/superset-frontend/src/embedded/guestTokenDiagnostics.test.ts
b/superset-frontend/src/embedded/guestTokenDiagnostics.test.ts
new file mode 100644
index 00000000000..cc5e27d3b03
--- /dev/null
+++ b/superset-frontend/src/embedded/guestTokenDiagnostics.test.ts
@@ -0,0 +1,101 @@
+/**
+ * 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 { TextEncoder } from 'util';
+import {
+ measureGuestToken,
+ guestAuthenticationMessage,
+} from './guestTokenDiagnostics';
+
+beforeAll(() => {
+ Object.assign(global, { TextEncoder });
+});
+
+test.each([
+ [20, true],
+ [21, false],
+ [22, false],
+ [null, false],
+ [0, false],
+ [-1, false],
+])('header budget %s: exceeded=%s', (budget, exceeded) => {
+ expect(measureGuestToken('é'.repeat(3), 'X-Custom-É', budget)).toEqual({
+ tokenBytes: 6,
+ headerBytes: 21,
+ headerBudgetBytes: budget && budget > 0 ? budget : null,
+ headerBudgetExceeded: exceeded,
+ });
+});
+
+test('default header accounting and safe metadata only', () => {
+ const size = measureGuestToken('secret-token', undefined, 1);
+ expect(size.headerBytes).toBe(28);
+ expect(JSON.stringify(size)).not.toContain('secret-token');
+ expect(guestAuthenticationMessage(size)).toContain('may exceed');
+});
+
+test('no size evidence uses generic authentication message', () => {
+ expect(guestAuthenticationMessage()).not.toContain('may exceed');
+ expect(guestAuthenticationMessage(measureGuestToken('t'))).not.toContain(
+ 'may exceed',
+ );
+});
+
+test.each([
+ ['16384', null],
+ ['invalid', null],
+ [true, null],
+ [false, null],
+ [[], null],
+ [{}, null],
+ [20.5, null],
+ [NaN, null],
+ [Infinity, null],
+ [-Infinity, null],
+ [2 ** 53, null],
+ [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
+ [16384.0, 16384],
+])('normalizes configured budget %p to %p', (configured, expected) => {
+ const size = measureGuestToken('t', undefined, configured);
+ expect(size.headerBudgetBytes).toBe(expected);
+ expect(size.headerBudgetExceeded).toBe(false);
+});
+
+test.each([undefined, 400, 431, 494])(
+ 'uses header-size evidence for status %p',
+ status => {
+ expect(
+ guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
+ status,
+ }),
+ ).toContain('may exceed');
+ expect(
+ guestAuthenticationMessage(measureGuestToken('t', undefined, 100), {
+ status,
+ }),
+ ).not.toContain('may exceed');
+ },
+);
+
+test.each([401, 403, 413, 500])('keeps status %p generic', status => {
+ expect(
+ guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
+ status,
+ }),
+ ).not.toContain('may exceed');
+});
diff --git a/superset-frontend/src/embedded/guestTokenDiagnostics.ts
b/superset-frontend/src/embedded/guestTokenDiagnostics.ts
new file mode 100644
index 00000000000..564c6786df3
--- /dev/null
+++ b/superset-frontend/src/embedded/guestTokenDiagnostics.ts
@@ -0,0 +1,71 @@
+/**
+ * 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 { t } from '@apache-superset/core/translation';
+
+export type GuestTokenSize = {
+ tokenBytes: number;
+ headerBytes: number;
+ headerBudgetBytes: number | null;
+ headerBudgetExceeded: boolean;
+};
+
+/** Measure the encoded token without decoding or retaining credentials. */
+export function measureGuestToken(
+ token: string,
+ headerName = 'X-GuestToken',
+ budget?: unknown,
+): GuestTokenSize {
+ const encoder = new TextEncoder();
+ const tokenBytes = encoder.encode(token).length;
+ // HTTP/1-style accounting: name + ": " + value + CRLF, not wire compression.
+ const headerBytes = tokenBytes + encoder.encode(headerName).length + 4;
+ const headerBudgetBytes =
+ typeof budget === 'number' && Number.isSafeInteger(budget) && budget > 0
+ ? budget
+ : null;
+ return {
+ tokenBytes,
+ headerBytes,
+ headerBudgetBytes,
+ headerBudgetExceeded:
+ headerBudgetBytes !== null && headerBytes > headerBudgetBytes,
+ };
+}
+
+/** Diagnose from size evidence only; never inspect or log proxy response
bodies. */
+export function guestAuthenticationMessage(
+ size?: GuestTokenSize,
+ error?: unknown,
+): string {
+ const status =
+ typeof error === 'object' && error !== null && 'status' in error
+ ? error.status
+ : undefined;
+ // Explicit auth/server failures have other causes. Some non-JSON proxy
+ // failures lose their status during parsing, so size remains the evidence.
+ const possibleHeaderFailure =
+ status === undefined || status === 400 || status === 431 || status === 494;
+ return size?.headerBudgetExceeded && possibleHeaderFailure
+ ? t(
+ 'Embedded authentication failed. The guest token may exceed the
request-header size limit. Reduce the token payload; large inline RLS lists can
be replaced with an entitlements-table lookup.',
+ )
+ : t(
+ 'Something went wrong with embedded authentication. Check the dev
console for details.',
+ );
+}
diff --git a/superset-frontend/src/embedded/index.test.tsx
b/superset-frontend/src/embedded/index.test.tsx
index 031f9c4f3b6..b9e8b1caa7d 100644
--- a/superset-frontend/src/embedded/index.test.tsx
+++ b/superset-frontend/src/embedded/index.test.tsx
@@ -18,7 +18,14 @@
*/
// Mark this file as a module so its top-level declarations stay file-scoped
// (the file has no imports; modules are loaded via require() inside tests).
-export {};
+import { TextEncoder } from 'util';
+
+Object.assign(global, { TextEncoder });
+
+const mockConfig = {
+ GUEST_TOKEN_HEADER_NAME: 'X-Custom-Guest',
+ GUEST_TOKEN_HEADER_MAX_BYTES: 100,
+};
// Stable mock references so they survive jest.resetModules() between tests
// (a factory-created jest.fn() would otherwise be replaced on each reset,
@@ -59,13 +66,14 @@ jest.mock('src/components/UiConfigContext', () => ({
// Capture the guestToken handler that start() is wired to, so tests can
// re-trigger the handshake and assert the retry behavior.
+const mockSwitchboardInit = jest.fn();
const mockSwitchboard = {
handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
};
jest.mock('@superset-ui/switchboard', () => ({
__esModule: true,
default: {
- init: jest.fn(),
+ init: mockSwitchboardInit,
start: jest.fn(),
defineMethod: (name: string, fn: (arg: { guestToken: string }) => void) =>
{
if (name === 'guestToken') {
@@ -76,7 +84,8 @@ jest.mock('@superset-ui/switchboard', () => ({
},
}));
-jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });
+const mockSetupClient = jest.fn();
+jest.mock('src/setup/setupClient', () => mockSetupClient, { virtual: true });
jest.mock('src/views/store', () => ({
store: {
@@ -113,6 +122,7 @@ jest.mock('react-dom/client', () => ({
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
+ config: mockConfig,
embedded: { dashboard_id: '123', allowed_domains: [] },
common: {
application_root: '/',
@@ -147,55 +157,148 @@ function sendHandshake() {
);
}
-describe('embedded/index.tsx', () => {
- beforeEach(() => {
- jest.resetModules();
- mockSwitchboard.handler = undefined;
- mockSetupPlugins.mockReset();
- mockSetupAGGridModules.mockReset();
- mockLogging.error.mockClear();
- mockGetMeWithRole.mockReset();
- mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
- document.body.innerHTML = '<div id="app"></div>';
- });
+beforeEach(() => {
+ jest.resetModules();
+ mockSwitchboard.handler = undefined;
+ mockSetupPlugins.mockReset();
+ mockSetupAGGridModules.mockReset();
+ mockLogging.error.mockClear();
+ mockGetMeWithRole.mockReset();
+ mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
+ document.body.innerHTML = '<div id="app"></div>';
+});
- test('initializes AG Grid modules on bootstrap', async () => {
- mockSetupPlugins.mockImplementation(() => undefined);
- require('./index');
- await flush();
+test('initializes AG Grid modules on bootstrap', async () => {
+ mockSetupPlugins.mockImplementation(() => undefined);
+ require('./index');
+ await flush();
- expect(mockSetupAGGridModules).toHaveBeenCalled();
- });
+ expect(mockSetupAGGridModules).toHaveBeenCalled();
+});
- test('retries plugin setup after setupPlugins rejects, then bootstraps the
user', async () => {
- // First plugin setup throws; the second attempt (after a re-handshake)
succeeds.
- mockSetupPlugins
- .mockImplementationOnce(() => {
- throw new Error('setupPlugins failed');
- })
- .mockImplementation(() => undefined);
+test('retries plugin setup after setupPlugins rejects, then bootstraps the
user', async () => {
+ // First plugin setup throws; the second attempt (after a re-handshake)
succeeds.
+ mockSetupPlugins
+ .mockImplementationOnce(() => {
+ throw new Error('setupPlugins failed');
+ })
+ .mockImplementation(() => undefined);
+
+ require('./index');
+ await flush();
+
+ sendHandshake();
+ expect(mockSwitchboard.handler).toBeDefined();
+
+ // First guest token: plugin setup rejects, start() resets the guard and
+ // recreates pluginsReady so a retry can re-run setup.
+ mockSwitchboard.handler!({ guestToken: 'token-1' });
+ await flush();
+ expect(mockLogging.error).toHaveBeenCalled();
+ expect(mockGetMeWithRole).not.toHaveBeenCalled();
+ // The user gets a visible failure message rather than a blank #app.
+ expect(document.getElementById('app')!.innerHTML).toContain(
+ 'Something went wrong loading the dashboard',
+ );
+
+ // Second guest token retries: plugin setup now succeeds and the user loads.
+ mockSwitchboard.handler!({ guestToken: 'token-2' });
+ await flush();
+ expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
+ expect(mockGetMeWithRole).toHaveBeenCalled();
+});
+test.each([
+ ['short', { status: 400, text: '<html>proxy error</html>' }, false],
+ ['short', { status: 401 }, false],
+ ['x'.repeat(100), { status: 401 }, false],
+ ['x'.repeat(100), { status: 500 }, false],
+ ['x'.repeat(100), new SyntaxError('private response body'), true],
+])(
+ 'authentication failure uses size evidence, not response content',
+ async (token, error, targeted) => {
+ mockGetMeWithRole.mockRejectedValue(error);
require('./index');
await flush();
-
sendHandshake();
- expect(mockSwitchboard.handler).toBeDefined();
-
- // First guest token: plugin setup rejects, start() resets the guard and
- // recreates pluginsReady so a retry can re-run setup.
- mockSwitchboard.handler!({ guestToken: 'token-1' });
+ mockSwitchboard.handler!({ guestToken: token });
await flush();
- expect(mockLogging.error).toHaveBeenCalled();
- expect(mockGetMeWithRole).not.toHaveBeenCalled();
- // The user gets a visible failure message rather than a blank #app.
- expect(document.getElementById('app')!.innerHTML).toContain(
- 'Something went wrong loading the dashboard',
+ expect(
+ document.getElementById('app')!.textContent?.includes('may exceed'),
+ ).toBe(targeted);
+ expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
+ 'private response body',
);
-
- // Second guest token retries: plugin setup now succeeds and the user
loads.
- mockSwitchboard.handler!({ guestToken: 'token-2' });
+ expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
+ '<html>',
+ );
+ // Failed authentication still permits the existing retry.
+ mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
+ mockSwitchboard.handler!({ guestToken: 'replacement' });
await flush();
- expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
- expect(mockGetMeWithRole).toHaveBeenCalled();
- });
+ expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
+ },
+);
+
+test('oversized refresh updates diagnostics without restarting successful
auth', async () => {
+ mockLogging.warn.mockClear();
+ require('./index');
+ await flush();
+ sendHandshake();
+ mockSwitchboard.handler!({ guestToken: 'short' });
+ await flush();
+ mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
+ await flush();
+ expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
+ expect(mockLogging.warn).toHaveBeenLastCalledWith(
+ 'Guest token exceeds configured request-header budget',
+ {
+ tokenBytes: 100,
+ headerBytes: 118,
+ headerBudgetBytes: 100,
+ headerBudgetExceeded: true,
+ },
+ );
+ expect(mockSetupClient).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ guestToken: 'x'.repeat(100),
+ guestTokenHeaderName: 'X-Custom-Guest',
+ }),
+ );
+});
+
+test('refresh during pending authentication does not misattribute size
evidence', async () => {
+ let rejectRequest: (error: unknown) => void = () => {};
+ mockGetMeWithRole.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectRequest = reject;
+ }),
+ );
+ require('./index');
+ await flush();
+ sendHandshake();
+ mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
+ await flush();
+ mockSwitchboard.handler!({ guestToken: 'short' });
+ rejectRequest({ status: 400 });
+ await flush();
+ expect(document.getElementById('app')!.textContent).not.toContain(
+ 'may exceed',
+ );
+ expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
+
+ // Clearing the guard after failure lets a subsequent token retry
authentication.
+ mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
+ mockSwitchboard.handler!({ guestToken: 'retry' });
+ await flush();
+ expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
+});
+
+test('Switchboard does not log credential-bearing message bodies', async () =>
{
+ require('./index');
+ await flush();
+ sendHandshake();
+ expect(mockSwitchboardInit).toHaveBeenLastCalledWith(
+ expect.objectContaining({ debug: false }),
+ );
});
diff --git a/superset-frontend/src/embedded/index.tsx
b/superset-frontend/src/embedded/index.tsx
index 7d452f82946..4cea686be09 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -49,6 +49,11 @@ import {
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
import { validateMessageEvent } from './originValidation';
+import {
+ measureGuestToken,
+ guestAuthenticationMessage,
+ GuestTokenSize,
+} from './guestTokenDiagnostics';
// Defer plugin setup until after the language pack loads to prevent t() calls
in
// plugin control panel configs from being cached in English before
translations are ready.
@@ -177,6 +182,7 @@ if (!window.parent || window.parent === window) {
let displayedUnauthorizedToast = false;
let root: Root | null = null;
let started = false;
+let guestTokenSize: GuestTokenSize | undefined;
/**
* If there is a problem with the guest token, we will start getting
@@ -209,8 +215,10 @@ function start() {
endpoint: '/api/v1/me/roles/',
});
return pluginsReady.then(
- () =>
- getMeWithRole().then(
+ () => {
+ // Snapshot at dispatch, not at handshake: plugin loading can overlap
refresh.
+ const requestTokenSize = guestTokenSize;
+ return getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
@@ -225,18 +233,18 @@ function start() {
}
root.render(<EmbeddedApp />);
},
- err => {
+ (error: unknown) => {
// something is most likely wrong with the guest token; reset the
guard
// so a rehandshake with a valid token can retry.
- logging.error(err);
- showFailureMessage(
- t(
- 'Something went wrong with embedded authentication. Check the
dev console for details.',
- ),
- );
+ // A refresh while the request is in flight makes attribution
ambiguous.
+ const size =
+ requestTokenSize === guestTokenSize ? requestTokenSize : undefined;
+ logging.error('Embedded authentication failed', size);
+ showFailureMessage(guestAuthenticationMessage(size, error));
started = false;
},
- ),
+ );
+ },
err => {
// setupPlugins() or setupCodeOverrides() threw while preparing plugins;
// reset the guard and recreate pluginsReady so a retry actually re-runs
@@ -258,6 +266,17 @@ function start() {
* Configures SupersetClient with the correct settings for the embedded
dashboard page.
*/
function setupGuestClient(guestToken: string) {
+ guestTokenSize = measureGuestToken(
+ guestToken,
+ bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
+ bootstrapData.config?.GUEST_TOKEN_HEADER_MAX_BYTES,
+ );
+ if (guestTokenSize.headerBudgetExceeded) {
+ logging.warn(
+ 'Guest token exceeds configured request-header budget',
+ guestTokenSize,
+ );
+ }
setupClient({
appRoot: applicationRoot(),
guestToken,
@@ -268,18 +287,19 @@ function setupGuestClient(guestToken: string) {
window.addEventListener('message', function embeddedPageInitializer(event) {
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
- log('ignoring message unrelated to embedded comms', event);
+ log('ignoring message unrelated to embedded comms');
return;
}
const port = event.ports?.[0];
if (event.data.handshake === 'port transfer' && port) {
- log('message port received', event);
+ log('message port received');
Switchboard.init({
port,
name: 'superset',
- debug: debugMode,
+ // Switchboard debug logs message bodies, including guest-token
credentials.
+ debug: false,
});
Switchboard.defineMethod(
diff --git a/superset-frontend/src/types/bootstrapTypes.ts
b/superset-frontend/src/types/bootstrapTypes.ts
index 9c341034f38..45d47030fdb 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -181,7 +181,10 @@ export interface CommonBootstrapData {
export interface BootstrapData {
user?: BootstrapUser;
common: CommonBootstrapData;
- config?: any;
+ config?: {
+ GUEST_TOKEN_HEADER_NAME?: string;
+ GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
+ };
embedded?: {
dashboard_id: string;
// Domains allowed to embed this dashboard. An empty/undefined list means
diff --git a/superset/config.py b/superset/config.py
index 6886ee48df8..00091152314 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -3029,6 +3029,9 @@ GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_SECRET = CHANGE_ME_GUEST_TOKEN_JWT_SECRET
GUEST_TOKEN_JWT_ALGO = "HS256" # noqa: S105
GUEST_TOKEN_HEADER_NAME = "X-GuestToken" # noqa: S105
+# Diagnostic budget for UTF-8 bytes of "header-name: encoded-token\r\n".
+# None disables size warnings, not issuance or authentication.
Deployment-specific.
+GUEST_TOKEN_HEADER_MAX_BYTES: int | None = None
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
# Audience for the Superset guest token used in embedded mode.
# Can be a string or a callable. Defaults to WEBDRIVER_BASEURL.
diff --git a/superset/embedded/view.py b/superset/embedded/view.py
index 833035822b1..cc5ecd17255 100644
--- a/superset/embedded/view.py
+++ b/superset/embedded/view.py
@@ -97,7 +97,12 @@ class EmbeddedView(BaseSupersetView):
bootstrap_data = {
"config": {
- "GUEST_TOKEN_HEADER_NAME":
current_app.config["GUEST_TOKEN_HEADER_NAME"]
+ "GUEST_TOKEN_HEADER_NAME": current_app.config[
+ "GUEST_TOKEN_HEADER_NAME"
+ ],
+ "GUEST_TOKEN_HEADER_MAX_BYTES": current_app.config[
+ "GUEST_TOKEN_HEADER_MAX_BYTES"
+ ],
},
"common": common_bootstrap_payload(),
"embedded": {
diff --git a/superset/security/api.py b/superset/security/api.py
index 63291e14d54..ccb6371526b 100644
--- a/superset/security/api.py
+++ b/superset/security/api.py
@@ -245,15 +245,23 @@ class SecurityRestApi(BaseSupersetApi):
body["rls"],
**({"datasets": body["datasets"]} if "datasets" in body else
{}),
)
- logger.info(
- "Guest token issued: %s",
- build_guest_token_audit_payload(
- issuer_user_id=get_user_id(),
- source_ip=request.remote_addr,
- body=body,
- token=token,
- ),
+ audit_payload = build_guest_token_audit_payload(
+ issuer_user_id=get_user_id(),
+ source_ip=request.remote_addr,
+ body=body,
+ token=token,
+ header_name=current_app.config["GUEST_TOKEN_HEADER_NAME"],
+
header_budget_bytes=current_app.config["GUEST_TOKEN_HEADER_MAX_BYTES"],
)
+ logger.info("Guest token issued: %s", audit_payload)
+ if audit_payload["header_budget_exceeded"]:
+ logger.warning(
+ "Guest token exceeds configured request-header budget: "
+ "token_bytes=%s header_bytes=%s header_budget_bytes=%s",
+ audit_payload["token_bytes"],
+ audit_payload["header_bytes"],
+ audit_payload["header_budget_bytes"],
+ )
return self.response(200, token=token)
except EmbeddedDashboardNotFoundError as error:
return self.response_400(message=error.message)
diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py
index 82111e4be72..a40cfaf646c 100644
--- a/superset/security/guest_token.py
+++ b/superset/security/guest_token.py
@@ -31,6 +31,8 @@ def build_guest_token_audit_payload(
source_ip: Optional[str],
body: dict[str, Any],
token: str,
+ header_name: str = "X-GuestToken",
+ header_budget_bytes: object = None,
) -> dict[str, Any]:
"""Build security-relevant metadata for a guest-token issuance event.
@@ -40,7 +42,24 @@ def build_guest_token_audit_payload(
"""
resources = body.get("resources") or []
rls = body.get("rls") or []
+ token_bytes = len(token.encode("utf-8"))
+ # HTTP/1-style accounting: name + colon-space + value + CRLF.
+ header_bytes = token_bytes + len(header_name.encode("utf-8")) + 4
+ # Match JavaScript's positive safe-integer budget, without coercing
settings.
+ # Invalid deployment values must not turn successful issuance into an
error.
+ budget = (
+ int(header_budget_bytes)
+ if isinstance(header_budget_bytes, (int, float))
+ and not isinstance(header_budget_bytes, bool)
+ and 0 < header_budget_bytes <= 2**53 - 1
+ and int(header_budget_bytes) == header_budget_bytes
+ else None
+ )
return {
+ "token_bytes": token_bytes,
+ "header_bytes": header_bytes,
+ "header_budget_bytes": budget,
+ "header_budget_exceeded": budget is not None and header_bytes > budget,
"issuer_user_id": issuer_user_id,
"source_ip": source_ip,
"resources": [
diff --git a/tests/integration_tests/embedded/test_view.py
b/tests/integration_tests/embedded/test_view.py
index d58fbd0408f..266565aa9dc 100644
--- a/tests/integration_tests/embedded/test_view.py
+++ b/tests/integration_tests/embedded/test_view.py
@@ -52,17 +52,30 @@ def _extract_bootstrap_data(response_data: bytes) ->
dict[str, Any]:
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
-def test_get_embedded_dashboard(client: FlaskClient[Any]): # noqa: F811
[email protected]("budget", [None, 16384])
+def test_get_embedded_dashboard(
+ client: FlaskClient[Any], # noqa: F811
+ budget: int | None,
+) -> None:
dash = db.session.query(Dashboard).filter_by(slug="births").first()
embedded = EmbeddedDashboardDAO.upsert(dash, [])
db.session.flush()
uri = f"embedded/{embedded.uuid}"
- response = client.get(uri)
+ with mock.patch.dict(
+ client.application.config,
+ GUEST_TOKEN_HEADER_NAME="X-Custom-Guest", # noqa: S106
+ GUEST_TOKEN_HEADER_MAX_BYTES=budget,
+ ):
+ 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"] == []
+ assert bootstrap["config"] == {
+ "GUEST_TOKEN_HEADER_NAME": "X-Custom-Guest",
+ "GUEST_TOKEN_HEADER_MAX_BYTES": budget,
+ }
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
diff --git a/tests/unit_tests/security/guest_token_audit_test.py
b/tests/unit_tests/security/guest_token_audit_test.py
index 81344b33044..2f6c1f21e55 100644
--- a/tests/unit_tests/security/guest_token_audit_test.py
+++ b/tests/unit_tests/security/guest_token_audit_test.py
@@ -17,6 +17,11 @@
"""Tests for guest-token issuance audit metadata."""
import hashlib
+import inspect
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
from superset.security.guest_token import build_guest_token_audit_payload
@@ -72,3 +77,85 @@ def
test_build_guest_token_audit_payload_omits_rls_clause_text() -> None:
# Clause text (which can carry data values) is not recorded.
assert "secret_value = 'pii'" not in str(payload)
assert payload["rls_datasets"] == [7]
+
+
[email protected](
+ "budget,exceeded",
+ [(20, True), (21, False), (22, False), (None, False), (0, False), (-1,
False)],
+)
+def test_guest_token_size_budget(budget: int | None, exceeded: bool) -> None:
+ """Budget includes UTF-8 header name, colon-space, token and CRLF."""
+ payload = build_guest_token_audit_payload(
+ None, None, {}, "é" * 3, header_name="X-Custom-É",
header_budget_bytes=budget
+ )
+ assert payload["token_bytes"] == 6
+ assert payload["header_bytes"] == 21
+ assert payload["header_budget_exceeded"] is exceeded
+
+
[email protected](
+ "budget,warning",
+ [(None, False), (19, True), (20, False), (21, False), ("19", False),
(True, False)],
+)
+def test_guest_token_issuance_preserves_response(budget: object, warning:
bool) -> None:
+ """Diagnostics neither reject issuance nor change the encoded token or
grants."""
+ from superset.security.api import SecurityRestApi
+
+ app = Flask(__name__)
+ app.config.update(
+ GUEST_TOKEN_HEADER_NAME="X-Test", # noqa: S106
+ GUEST_TOKEN_HEADER_MAX_BYTES=budget,
+ )
+ api = MagicMock()
+ token = "encodedjwt" # noqa: S105
+ api.appbuilder.sm.create_guest_access_token.return_value = token
+ body = {
+ "user": {"username": "guest"},
+ "resources": [],
+ "rls": [{"clause": "private_sql = 1"}],
+ }
+ with (
+ app.test_request_context(json=body),
+ patch("superset.security.api.guest_token_create_schema") as schema,
+ patch("superset.security.api.get_user_id", return_value=1),
+ patch("superset.security.api.logger") as log,
+ ):
+ schema.load.return_value = body
+ result = inspect.unwrap(SecurityRestApi.guest_token)(api)
+ api.response.assert_called_once_with(200, token=token)
+ assert result is api.response.return_value
+ api.appbuilder.sm.create_guest_access_token.assert_called_once_with(
+ body["user"], body["resources"], body["rls"]
+ )
+ assert log.warning.called is warning
+ assert token not in str(log.mock_calls)
+ assert "private_sql" not in str(log.mock_calls)
+
+
[email protected](
+ "configured,expected",
+ [
+ ("16384", None),
+ ("invalid", None),
+ (True, None),
+ (False, None),
+ ([], None),
+ ({}, None),
+ (20.5, None),
+ (float("nan"), None),
+ (float("inf"), None),
+ (float("-inf"), None),
+ (2**53, None),
+ (2**53 - 1, 2**53 - 1),
+ (16384.0, 16384),
+ ],
+)
+def test_guest_token_budget_normalization(
+ configured: object, expected: int | None
+) -> None:
+ """Normalize invalid configuration safely and match browser integer
semantics."""
+ payload = build_guest_token_audit_payload(
+ None, None, {}, "t", header_budget_bytes=configured
+ )
+ assert payload["header_budget_bytes"] == expected
+ assert payload["header_budget_exceeded"] is False