Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ Asynchronous execution paths do not create a separate data-access capability. A

Task metadata is itself a request-scoped resource. Non-admin users and embedded guests may read or cancel only tasks they are subscribed to or that otherwise represent work they are entitled to observe; Admin may observe and manage tasks as part of the trusted operational boundary. A bug that lets a principal create, read, join, cancel, or receive task state for work outside the role and capability matrix is in scope.

Realtime transports, including WebSocket delivery backed by Redis or Valkey Pub/Sub, are notification mechanisms rather than authorization sources. WebSocket notification access is controlled by `can_read` on the `Realtime` resource. The broadcast scope is authenticated-global, not public: it reaches every authenticated realtime socket, and an anonymous request receives no realtime principal, no JWT cookie, and therefore no socket, so it never receives these messages (true anonymous/Public-role realtime is not offered and would require a separate, restricted model). Broadcast Pub/Sub messages, such as list-view entity-change events, must be context-free nudges; sensitive or authoritative state must not be published on the broadcast scope. Targeted Pub/Sub messages may carry task state only when the producer names routing keys derived from an authorized identity, such as a task subscriber's principal channel (or a per-tab channel derived from it); the producer validates every routing key against the task's own subscriber principals before publishing, and the websocket server forwards the payload only to sockets bound to those keys. Full data and result payloads must still be fetched through the normal protected REST API or cache-read path. Redis Streams used for task completion, dependency, and lock-release signalling are likewise coordination signals; the metastore or cache entry they wake a consumer to read remains the source of truth.
Realtime transports, including WebSocket delivery backed by Redis or Valkey Pub/Sub, are notification mechanisms rather than authorization sources. WebSocket notification access is controlled by `can_read` on the `Realtime` resource. The broadcast scope is authenticated-global, not public: it reaches every authenticated realtime socket, and an anonymous request receives no realtime principal, no connection ticket, and therefore no socket, so it never receives these messages (true anonymous/Public-role realtime is not offered and would require a separate, restricted model). Broadcast Pub/Sub messages, such as list-view entity-change events, must be context-free nudges; sensitive or authoritative state must not be published on the broadcast scope. Targeted Pub/Sub messages may carry task state only when the producer names routing keys derived from an authorized identity, such as a task subscriber's principal channel (or a per-tab channel derived from it); the producer validates every routing key against the task's own subscriber principals before publishing, and the websocket server forwards the payload only to sockets bound to those keys. Full data and result payloads must still be fetched through the normal protected REST API or cache-read path. Redis Streams used for task completion, dependency, and lock-release signalling are likewise coordination signals; the metastore or cache entry they wake a consumer to read remains the source of truth.

The realtime notification permission is distinct from the permission to read the underlying object. It controls whether a principal receives push notifications, not whether they may read the object once they call the protected REST API. Existing websocket connections are authorized by the JWT accepted at upgrade time; permission revocation after token minting is bounded by `WEBSOCKET_JWT_EXPIRATION_SECONDS` plus the websocket server's socket-check interval. Redis Streams are internal server-to-server coordination primitives and should not be directly exposed as an end-user subscription surface.
The realtime notification permission is distinct from the permission to read the underlying object. It controls whether a principal receives push notifications, not whether they may read the object once they call the protected REST API. An open websocket connection is authorized by the most recent connection ticket presented on it (the first `auth` frame, then each in-band refresh); permission revocation after a ticket is minted is bounded by `WEBSOCKET_JWT_EXPIRATION_SECONDS` plus the websocket server's socket-check interval, because the next refresh is refused. Redis Streams are internal server-to-server coordination primitives and should not be directly exposed as an end-user subscription surface.

### Version History and Change Metadata

Expand Down
26 changes: 19 additions & 7 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,14 +1248,26 @@ WEBSOCKET_JWT_SECRET = "<output of: openssl rand -base64 42>"
The built-in Gamma role receives `can_read Realtime`; grant that permission to
custom roles that should receive websocket notifications.

Run the `superset-websocket` Node server on the **same browser-visible host**
(so its JWT channel cookie is shared) and point its `redis` config at the same
instance as `DISTRIBUTED_COORDINATION_CONFIG`, plus `jwtSecret` /
`jwtCookieName` matching the Flask config (`WEBSOCKET_JWT_SECRET` /
`WEBSOCKET_JWT_COOKIE_NAME`, default `superset-ws-token`). During websocket JWT
Run the `superset-websocket` Node server where browsers can reach it and point
its `redis` config at the same instance as `DISTRIBUTED_COORDINATION_CONFIG`,
plus a `jwtSecret` matching the Flask config (`WEBSOCKET_JWT_SECRET`). The
browser authenticates with a short-lived connection ticket fetched from
`GET /api/v1/realtime/token/` and sent as the first frame of the socket, then
refreshes it on the open socket before it expires, so the server no longer needs
to share a host with the web application and no credential rides the handshake or
the URL. Upgrade the web servers and the websocket servers together, and reload
open browser tabs: a tab still running the previous cookie-based client is
refused by the new server (it closes the socket after the 10 second auth
timeout, repeatedly) until it is reloaded, and the same applies to replicas rolled
at different times. The `WEBSOCKET_JWT_COOKIE_NAME`,
`WEBSOCKET_JWT_COOKIE_SECURE`, `WEBSOCKET_JWT_COOKIE_SAMESITE` and
`WEBSOCKET_JWT_COOKIE_DOMAIN` Flask settings and the websocket server's
`jwtCookieName` / `JWT_COOKIE_NAME` option are removed. During websocket JWT
secret rotation, set the websocket server's `previousJwtSecret` /
`PREVIOUS_JWT_SECRET` to the old key while Flask continues minting cookies with
`WEBSOCKET_JWT_SECRET`. The server is bundled in the official Superset image
`PREVIOUS_JWT_SECRET` to the old key while Flask continues minting tickets with
`WEBSOCKET_JWT_SECRET`. After a logout or session revocation, an open socket stays
authenticated until its current ticket expires, because the next refresh fails.
The server is bundled in the official Superset image
and launched via an alternate entrypoint — no separate image is required:
`docker run <superset-image> /app/docker/entrypoints/run-websocket.sh` (or the
opt-in `websocket` profile in `docker compose`). It **subscribes** to a single
Expand Down
1 change: 0 additions & 1 deletion docker-compose-non-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
PORT: 8080
JWT_COOKIE_NAME: superset-ws-token
# Dev-only default; must match the app's WEBSOCKET_JWT_SECRET and be
# replaced with a strong secret (>= 32 bytes) outside local development.
JWT_SECRET: ${WEBSOCKET_JWT_SECRET:-dev-only-websocket-secret-change-me!}
Expand Down
1 change: 0 additions & 1 deletion docker/entrypoints/run-websocket.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
# the authoritative, complete set (Redis connection, logging, connection limits,
# StatsD, etc.). The values that MUST match the Flask app's config are:
# JWT_SECRET == WEBSOCKET_JWT_SECRET
# JWT_COOKIE_NAME == WEBSOCKET_JWT_COOKIE_NAME (default superset-ws-token)
# REALTIME_CHANNEL_PREFIX == Flask REALTIME_CHANNEL_PREFIX (default empty; set a
# per-deployment value on both sides to isolate a shared Redis/Valkey)
# Optional rotation setting:
Expand Down
3 changes: 1 addition & 2 deletions docker/superset-websocket/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,5 @@
},
"jwtAlgorithms": ["HS256"],
"jwtSecret": "CHANGE-ME-IN-PRODUCTION-GOTTA-BE-LONG-AND-SECRET",
"previousJwtSecret": "",
"jwtCookieName": "superset-ws-token"
"previousJwtSecret": ""
}
4 changes: 2 additions & 2 deletions docs/admin_docs/security/securing_superset.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,14 @@ https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotat
| --------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
| `WEBSOCKET_JWT_SECRET` | Signs the realtime websocket channel JWT cookie | Forged websocket tokens → unauthorized realtime notifications | Quarterly + post-incident |
| `WEBSOCKET_JWT_SECRET` | Signs the realtime websocket connection tickets | Forged websocket tokens → unauthorized realtime notifications | Quarterly + post-incident |
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |

Notes:

- Rotating `GUEST_TOKEN_JWT_SECRET` or `WEBSOCKET_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
- `WEBSOCKET_JWT_SECRET` can be rotated without disconnecting live sockets: set the outgoing value as `PREVIOUS_JWT_SECRET` on the websocket server so it keeps verifying old cookies, then remove it once they have aged out.
- `WEBSOCKET_JWT_SECRET` can be rotated without disconnecting live sockets: set the outgoing value as `PREVIOUS_JWT_SECRET` on the websocket server so it keeps verifying old tickets, then remove it once they have aged out.
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.

Expand Down
21 changes: 21 additions & 0 deletions superset-frontend/src/embedded/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ jest.mock('@superset-ui/switchboard', () => ({
const mockSetupClient = jest.fn();
jest.mock('src/setup/setupClient', () => mockSetupClient, { virtual: true });

const mockConnectRealtime = jest.fn();
jest.mock('src/middleware/realtime', () => ({
connectRealtime: () => mockConnectRealtime(),
}));

jest.mock('src/views/store', () => ({
store: {
dispatch: jest.fn(),
Expand Down Expand Up @@ -267,6 +272,22 @@ test('oversized refresh updates diagnostics without restarting successful auth',
);
});

test('reconnects realtime once the guest client is configured', async () => {
require('./index');
await flush();
sendHandshake();
mockSetupClient.mockClear();
mockConnectRealtime.mockClear();

mockSwitchboard.handler!({ guestToken: 'short' });
await flush();

expect(mockConnectRealtime).toHaveBeenCalledTimes(1);
expect(mockSetupClient.mock.invocationCallOrder[0]).toBeLessThan(
mockConnectRealtime.mock.invocationCallOrder[0],
);
});

test('refresh during pending authentication does not misattribute size evidence', async () => {
let rejectRequest: (error: unknown) => void = () => {};
mockGetMeWithRole.mockReturnValue(
Expand Down
5 changes: 5 additions & 0 deletions superset-frontend/src/embedded/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import initPreamble from 'src/preamble';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import setupClient from 'src/setup/setupClient';
import { connectRealtime } from 'src/middleware/realtime';
import { useUiConfig } from 'src/components/UiConfigContext';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
Expand Down Expand Up @@ -284,6 +285,10 @@ function setupGuestClient(guestToken: string) {
guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
unauthorizedHandler: guestUnauthorizedHandler,
});
// The realtime socket first connects at module load, before a guest token
// exists, and its ticket request is refused. Reconnect with the guest
// credential; a no-op while a socket is already open.
connectRealtime();
}

window.addEventListener('message', function embeddedPageInitializer(event) {
Expand Down
39 changes: 39 additions & 0 deletions superset-frontend/src/middleware/asyncEvent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ jest.mock('src/hooks/useTabId', () => {
/* eslint-disable no-var, vars-on-top */
var mockRealtimeOpenListener: (() => void) | undefined;
var mockRealtimeStateListener: ((state: string) => void) | undefined;
var mockRealtimeState: string;
/* eslint-enable no-var, vars-on-top */
jest.mock('src/middleware/realtime', () => ({
connectRealtime: jest.fn(),
getRealtimeState: () => mockRealtimeState ?? 'open',
subscribeRealtime: jest.fn(() => () => {}),
subscribeRealtimeOpen: (listener: () => void) => {
mockRealtimeOpenListener = listener;
Expand All @@ -55,6 +57,7 @@ jest.mock('src/middleware/realtime', () => ({
},
subscribeRealtimeState: (listener: (state: string) => void) => {
mockRealtimeStateListener = listener;
mockRealtimeState = 'open';
return () => {
mockRealtimeStateListener = undefined;
};
Expand Down Expand Up @@ -857,6 +860,42 @@ test('WS mode: an unhealthy socket settles pending waiters with a bounded error'
expect(refetch).not.toHaveBeenCalled();
});

test('WS mode: an unauthorized socket settles pending waiters with a bounded error', async () => {
queueStatuses();
asyncEvent.init(wsConfig);

const refetch = jest.fn();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
await new Promise(resolve => {
setTimeout(resolve, 0);
});

// The session lost the realtime permission: no socket will ever settle this.
mockRealtimeStateListener?.('unauthorized');

await expect(promise).rejects.toThrow('Realtime connection unavailable');
expect(refetch).not.toHaveBeenCalled();
});

test.each(['unauthorized', 'unhealthy'])(
'WS mode: a waiter registered while the socket is %s settles immediately',
async state => {
queueStatuses();
asyncEvent.init(wsConfig);
mockRealtimeState = state;

const refetch = jest.fn();
await expect(
asyncEvent.waitForAsyncData({ task_ids: ['task-1'] }, refetch),
).rejects.toThrow('Realtime connection unavailable');
expect(refetch).not.toHaveBeenCalled();
mockRealtimeState = 'open';
},
);

test('WS mode: coalesces many same-tick registrations into one catch-up', async () => {
jest.useFakeTimers();
queueStatuses(); // all catch-ups see no change
Expand Down
18 changes: 17 additions & 1 deletion superset-frontend/src/middleware/asyncEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { getTabId } from 'src/hooks/useTabId';
import { getAsyncQueryError } from './asyncQueryError';
import {
connectRealtime,
getRealtimeState,
subscribeRealtime,
subscribeRealtimeOpen,
subscribeRealtimeState,
Expand Down Expand Up @@ -292,6 +293,11 @@ const abandonRealtimeWaiters = () => {
);
};

const isRealtimeDown = (): boolean => {
const state = getRealtimeState();
return state === 'unhealthy' || state === 'unauthorized';
};

const finishStatus = (taskId: string, status: string, message?: string) => {
const waiters = waitersByTaskId.get(taskId);
if (!waiters) return;
Expand Down Expand Up @@ -507,7 +513,9 @@ subscribeRealtimeState(state => {
if (!wsEnabled || !waitersByTaskId.size) return;
if (state === 'reconnecting') {
scheduleCatchUp();
} else if (state === 'unhealthy') {
} else if (state === 'unhealthy' || state === 'unauthorized') {
// `unauthorized` is terminal (the session lost the realtime permission or
// ended), so no socket will ever settle these waiters.
abandonRealtimeWaiters();
}
});
Expand Down Expand Up @@ -607,6 +615,14 @@ export const waitForAsyncData = async <T = unknown[]>(
}
waiters.add(waiter);
});
// The realtime connection state is the source of truth: a waiter registering
// while the transport is already terminally down (`unauthorized`) or
// `unhealthy` is settled at once, not left to wait on a transition that
// already happened.
if (wsEnabled && isRealtimeDown()) {
settle(waiter, new Error('Realtime connection unavailable'));
return;
}
if (wsEnabled) {
// WS is the transport (no polling). Bound a genuinely-lost completion (a
// dropped socket message with no reconnect) so a chart can't spin forever;
Expand Down
Loading
Loading