sadpandajoe commented on code in PR #45404:
URL: https://github.com/apache/superset/pull/45404#discussion_r4450472932


##########
superset-frontend/src/embedded/index.tsx:
##########
@@ -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();

Review Comment:
   This re-read can't turn realtime on for embedded guests. The embedded shell 
is rendered for an anonymous user, so `common_bootstrap_payload()` already sets 
`WEBSOCKET_ENABLE` to `false` unless the Public role holds `can_read Realtime`, 
and `connectRealtime()` reads that same unchanged bootstrap value after the 
guest token arrives. With the guest role granted `can_read Realtime` (and 
Public not), no ticket is requested and no socket opens. The new test mocks 
`connectRealtime`, so it wouldn't catch this. Should this pass a config with 
the websocket enabled once the guest token is set, or is realtime for embedded 
guests intentionally off?



##########
superset-frontend/src/middleware/realtime.ts:
##########
@@ -267,16 +301,140 @@ const teardownSocket = (): void => {
   }
 };
 
+interface Ticket {
+  token: string;
+  expiresInMs: number;
+}
+
+/**
+ * Fetch a fresh connection ticket for the current session. A 401 must not run
+ * the client's unauthorized handler (a login redirect, or the embedded guest
+ * handler): the realtime transport is optional, and the rejection is handled 
by
+ * `isUnauthorizedError`.
+ */
+const fetchTicket = async (): Promise<Ticket> => {
+  const { json } = await SupersetClient.get({
+    endpoint: TICKET_ENDPOINT,
+    ignoreUnauthorized: true,
+  });
+  const { token, expires_in: expiresIn } = (json ?? {}) as {
+    token?: unknown;
+    expires_in?: unknown;
+  };
+  if (typeof token !== 'string' || typeof expiresIn !== 'number') {
+    throw new Error('Malformed realtime ticket response');
+  }
+  return { token, expiresInMs: expiresIn * 1000 };
+};
+
+/**
+ * Whether a ticket request failed because the session may not use the 
transport
+ * (no session, or no realtime permission). Retrying cannot succeed.
+ */
+const isUnauthorizedError = (err: unknown): boolean => {
+  const status = (err as { status?: unknown } | null)?.status;
+  return status === 401 || status === 403;
+};
+
+// Tab ids carried by `auth` frames the server has not acknowledged yet, oldest
+// first, and the tab id the server last acknowledged. The server answers every
+// frame in order, so comparing the two tells whether an ack rebound the tab.
+let unackedTabIds: string[] = [];
+let ackedTabId: string | undefined;
+
+const sendAuthFrame = (ws: WebSocket, token: string): void => {
+  const tabId = getTabId();
+  ws.send(JSON.stringify({ type: 'auth', token, tab_id: tabId }));
+  unackedTabIds.push(tabId);
+};
+
+const notifyOpen = (reason: RealtimeOpenReason): void => {
+  openListeners.forEach(listener => {
+    try {
+      listener(reason);
+    } catch (err) {
+      logging.warn('Realtime open-listener error', err);
+    }
+  });
+};
+
+// Stop connecting after the server refused this session. Closes any live 
socket
+// without scheduling a reconnect.
+const enterUnauthorized = (): void => {

Review Comment:
   `unauthorized` is a dead end for the rest of the page's life in the main 
app: nothing but a new `connectRealtime()` call leaves it, and the only caller 
is the one-time `asyncEvent` init. Repro: log out in another tab, let this idle 
tab's ticket refresh get a 401 (socket torn down, no redirect because of 
`ignoreUnauthorized`), log back in, then change a filter here. The chart POST 
succeeds, but the waiter is rejected immediately with `Realtime connection 
unavailable` and polling is off, so async charts fail until a reload. Before 
this change the client kept retrying and recovered once the session was valid 
again. Could a 401/403 use a long backoff retry (or re-arm on focus or after a 
successful API response) instead of staying terminal?



##########
superset-websocket/src/index.ts:
##########
@@ -715,72 +738,245 @@ export const readSocketIdentity = (
   };
 };
 
+/** An inbound `auth` frame: `{type: 'auth', token, tab_id?}`. */
+interface AuthFrame {
+  token: string;
+  tabId?: string;
+}
+
 /**
- * WebSocket `connection` event handler. `identity` is the one already verified
- * during the HTTP upgrade (see `httpUpgrade`), so each connection's JWT is
- * verified exactly once.
+ * Parse a raw inbound frame into an `auth` frame, or `null` when it is 
anything
+ * else (malformed JSON, another message type, or a missing token). An invalid 
or
+ * absent `tab_id` is dropped rather than failing the frame, so the socket 
falls
+ * back to principal-grain delivery.
  */
-export const wsConnection = (
-  ws: WebSocket,
-  identity: SocketIdentity,
-  tabId?: string,
-) => {
-  const { channel } = identity;
-
-  // Refuse the connection if a configured connection limit has been reached,
-  // before tracking it against the internal registries. The cap is keyed on 
the
-  // principal channel (which counts all of a principal's tabs); the per-tab
-  // channel is a delivery refinement and is not separately limit-checked.
-  const limitReason = connectionLimitReason(channel);
-  if (limitReason) {
-    statsd.increment('ws_connection_rejected');
-    logger.warn(`Refusing connection on channel ${channel}: ${limitReason}`);
-    ws.close(CONNECTION_LIMIT_CLOSE_CODE, limitReason);
-    return;
+const parseAuthFrame = (raw: WebSocket.RawData): AuthFrame | null => {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(raw.toString());
+  } catch {
+    return null;
   }
+  if (!parsed || typeof parsed !== 'object') return null;
+  const {
+    type,
+    token,
+    tab_id: tabId,
+  } = parsed as {
+    type?: unknown;
+    token?: unknown;
+    tab_id?: unknown;
+  };
+  if (type !== 'auth' || typeof token !== 'string' || token.length === 0) {
+    return null;
+  }
+  return {
+    token,
+    tabId: typeof tabId === 'string' && isValidTabId(tabId) ? tabId : 
undefined,
+  };
+};
 
-  // When the browser advertised a tab id at connect, also register the socket
-  // under a per-tab channel derived from its authorized principal channel
-  // (`<channel>:<tabId>`). The tab id is client-supplied but always prefixed 
by
-  // the authorized principal channel, so it can never address another
-  // principal's sockets. Task-status can then target one tab, while the
-  // principal channel still reaches all of a principal's tabs.
-  const tabChannel = tabId ? `${channel}:${tabId}` : undefined;
-
-  const socketInstance: SocketInstance = {
-    ws,
-    channel,
-    tabChannel,
-    identity,
-    pongTs: Date.now(),
+// Sent through the bounded send path: a client replaying auth frames without
+// reading replies hits the same buffer cap as any other outbound message.
+const sendAuthOk = (socketId: string, identity: SocketIdentity): void => {
+  const message: OutboundMessage = {
+    topic: AUTH_REPLY_TOPIC,
+    payload: {
+      status: 'ok',
+      expires_in: Math.max(
+        0,
+        Math.floor((identity.tokenExpiresAtMs - Date.now()) / 1000),
+      ),
+    },
   };
+  sendToSocket(socketId, JSON.stringify(message));
+};
+
+/**
+ * Removes a socket id from a channel's socket list, dropping the channel when 
it
+ * becomes empty.
+ */
+const untrackFromChannel = (channel: string, socketId: string): void => {
+  const entry = channels[channel];
+  if (!entry) return;
+  entry.sockets = entry.sockets.filter(id => id !== socketId);
+  if (entry.sockets.length === 0) delete channels[channel];
+};
 
-  // Register once (mints a single socket id), then also index that same id 
under
-  // the per-tab channel - never call trackClient twice (it would mint a second
-  // id and a duplicate registry entry).
-  const socketId = trackClient(channel, socketInstance);
+/**
+ * Binds an already tracked socket to the per-tab channel derived from its
+ * authorized principal channel (`<channel>:<tabId>`), replacing a previous
+ * binding. The tab id is client-supplied but always prefixed by the authorized
+ * principal channel, so it can never address another principal's sockets.
+ * Task-status can then target one tab, while the principal channel still 
reaches
+ * all of a principal's tabs.
+ */
+const bindTabChannel = (
+  socketId: string,
+  socketInstance: SocketInstance,
+  tabId: string | undefined,
+): void => {
+  const tabChannel = tabId ? `${socketInstance.channel}:${tabId}` : undefined;
+  if (tabChannel === socketInstance.tabChannel) return;
+  if (socketInstance.tabChannel) {
+    untrackFromChannel(socketInstance.tabChannel, socketId);
+  }
   if (tabChannel) {
     (channels[tabChannel] ??= { sockets: [] }).sockets.push(socketId);
   }
-  logger.debug(
-    `socket ${socketId} connected on channel ${channel}` +
-      (tabChannel ? ` (tab channel ${tabChannel})` : ''),
-  );
+  socketInstance.tabChannel = tabChannel;
+};
+
+/**
+ * WebSocket `connection` event handler. The connection starts `pending_auth`:
+ * it is tracked in no registry and routed no message until its first `auth` 
frame
+ * carries a valid ticket, at which point it is registered on its principal 
channel
+ * (and tab channel). Later `auth` frames refresh the ticket on the open 
socket.
+ */
+export const wsConnection = (ws: WebSocket) => {
+  // Set once the first `auth` frame is accepted; `undefined` while 
pending_auth.
+  let socketId: string | undefined;
+
+  // Reserve capacity at upgrade time so unauthenticated sockets cannot bypass
+  // the configured caps while they wait out the auth window.
+  const { maxPendingConnections } = opts;
+  const limitReason = connectionLimitReason('');
+  if (
+    limitReason ||
+    (maxPendingConnections > 0 && pendingConnections >= maxPendingConnections)
+  ) {
+    statsd.increment('ws_connection_rejected');
+    logger.warn(
+      `Refusing connection: ${limitReason ?? 'pre-auth connection limit 
reached'}`,
+    );
+    ws.close(CONNECTION_LIMIT_CLOSE_CODE, limitReason ?? 'too many pending');
+    return;
+  }
+  pendingConnections += 1;
+  let pendingReleased = false;
+  const releasePending = () => {
+    if (pendingReleased) return;
+    pendingReleased = true;
+    pendingConnections -= 1;
+  };
+
+  const authTimer = setTimeout(() => {
+    if (socketId === undefined) {
+      statsd.increment('ws_auth_timeout');
+      releasePending();
+      ws.close(AUTH_CLOSE_CODE, 'authentication timeout');

Review Comment:
   Releasing the pending reservation and then calling `ws.close()` leaves the 
connection open until the peer answers the close frame, or until `ws`'s ~30s 
close timeout destroys it. A client that never replies is therefore uncounted 
after the first 10s, and the same holds for the limit-rejection `ws.close()` 
calls just above and below. With `maxPendingConnections` set to 100, an 
anonymous client that opens sockets, ignores the close frame and reconnects 
every 10s can hold several hundred sockets against a cap meant to bound them at 
100, and legitimate connects get refused with 1013 meanwhile. Would 
`ws.terminate()` on these paths (or releasing the slot from the `close` event) 
keep the caps meaningful?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to