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

Reply via email to