Skip to content
36 changes: 36 additions & 0 deletions src/main/ipc/app/drag-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// One-use drag token for cross-window tab drag-and-drop authentication.
//
// When an external drag starts, the source renderer generates a random token
// and registers it here via IPC alongside the tab ID being dragged. Only one
// token is active at a time — registering a new one replaces any previous one.
//
// When the drop target calls tabs:move-tab-to-window-space with a dragToken,
// validateAndConsumeToken is called to verify the token matches the registered
// tab and then immediately clears it so it cannot be reused.

interface ActiveDragToken {
token: string;
tabId: number;
}

let activeToken: ActiveDragToken | null = null;

/**
* Registers a new one-use drag token tied to a specific tab.
* Any previously registered token is discarded.
*/
export function registerToken(token: string, tabId: number): void {
activeToken = { token, tabId };
}
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale token persists after a cancelled drag

When a drag is initiated, registerToken is called immediately. If the user cancels the drag (presses Escape, releases outside a valid target, etc.), the token is never consumed and activeToken keeps the stale entry indefinitely. The stale token is only evicted if the user starts a new drag (which replaces activeToken) or completes a valid drop.

In practice the stale token cannot be replayed by the renderer — getInitialDataForExternal is a fire-and-forget closure and never stores the token in component state, so the renderer has no reference to it after the callback returns. The risk is low, but consider adding a cleanup on dragend or a short TTL to make the guarantee explicit:

// Option: auto-expire the token after a safe window (e.g. 30 s)
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;

export function registerToken(token: string, tabId: number): void {
  if (expiryTimeout) clearTimeout(expiryTimeout);
  activeToken = { token, tabId };
  expiryTimeout = setTimeout(() => { activeToken = null; }, 30_000);
}

export function validateAndConsumeToken(token: string, tabId: number): boolean {
  if (!activeToken) return false;
  if (activeToken.token !== token || activeToken.tabId !== tabId) return false;
  if (expiryTimeout) { clearTimeout(expiryTimeout); expiryTimeout = null; }
  activeToken = null;
  return true;
}


/**
* Validates the provided token against the active token and, if valid,
* consumes it so it cannot be used again. Returns true only if the token
* matches and is bound to the expected tab ID.
*/
export function validateAndConsumeToken(token: string, tabId: number): boolean {
if (!activeToken) return false;
if (activeToken.token !== token || activeToken.tabId !== tabId) return false;
activeToken = null;
return true;
}
80 changes: 49 additions & 31 deletions src/main/ipc/browser/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { tabsController } from "@/controllers/tabs-controller";
import { serializeTabForRenderer, serializeTabGroupForRenderer } from "@/saving/tabs/serialization";
import { recentlyClosedManager } from "@/saving/tabs/recently-closed";
import { GlanceTabGroup } from "@/controllers/tabs-controller/tab-groups/glance";
import { registerToken, validateAndConsumeToken } from "@/ipc/app/drag-token";

/**
* Attempts to restore a tab's group membership after it has been recreated.
Expand Down Expand Up @@ -335,46 +336,63 @@ ipcMain.handle("tabs:move-tab", async (event, tabId: number, newPosition: number
return true;
});

ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spaceId: string, newPosition?: number) => {
const webContents = event.sender;
const window = browserWindowsController.getWindowFromWebContents(webContents);
if (!window) return false;
ipcMain.on("drag:register-token", (_event, token: string, tabId: number) => {
registerToken(token, tabId);
});
Comment on lines +339 to +341
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drag:register-token does not verify tab ownership

The handler accepts any (token, tabId) pair without checking that tabId is a tab actually owned by the sending window. Any renderer context holding "browser" permission (i.e. any trusted browser-UI window) can pre-register a token for a tab in a different window, then immediately call moveTabToWindowSpace with that token to move the tab without a real drag event.

This is currently low-risk because all browser-UI windows are equally trusted, but tightening the check makes the security model explicit:

ipcMain.on("drag:register-token", (event, token: string, tabId: number) => {
  const webContents = event.sender;
  const senderWindow = browserWindowsController.getWindowFromWebContents(webContents);
  if (!senderWindow) return;

  // Ensure the tab being registered belongs to the sender's window
  const tab = tabsController.getTabById(tabId);
  if (!tab || tab.getWindow()?.id !== senderWindow.id) return;

  registerToken(token, tabId);
});


const tab = tabsController.getTabById(tabId);
if (!tab) return false;
ipcMain.handle(
"tabs:move-tab-to-window-space",
async (event, tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => {
const webContents = event.sender;
const window = browserWindowsController.getWindowFromWebContents(webContents);
if (!window) return false;

const space = await spacesController.get(spaceId);
if (!space) return false;
const tab = tabsController.getTabById(tabId);
if (!tab) return false;

// Capture source space before move (for normalizing after)
const sourceSpaceId = tab.spaceId;
const space = await spacesController.get(spaceId);
if (!space) return false;

// Collect all tabs to move (includes tab group members)
let targetTabs: Tab[] = [tab];
const tabGroup = tabsController.getTabGroupByTabId(tab.id);
if (tabGroup) {
targetTabs = tabGroup.tabs;
}
// For external cross-window drops a one-use drag token is required.
// Validate and consume it before proceeding so it cannot be replayed.
if (dragToken !== undefined) {
if (!validateAndConsumeToken(dragToken, tabId)) return false;
}

// Move all tabs in the group to the new space
for (const targetTab of targetTabs) {
targetTab.setSpace(spaceId);
targetTab.setWindow(window);
// Capture source location before move (for normalizing after)
const sourceSpaceId = tab.spaceId;
const sourceWindowId = tab.getWindow()?.id;

if (newPosition !== undefined) {
targetTab.updateStateProperty("position", newPosition);
// Collect all tabs to move (includes tab group members)
let targetTabs: Tab[] = [tab];
const tabGroup = tabsController.getTabGroupByTabId(tab.id);
if (tabGroup) {
targetTabs = tabGroup.tabs;
}
}

// Normalize positions in both source and target spaces
tabsController.normalizePositions(window.id, spaceId);
if (sourceSpaceId !== spaceId) {
tabsController.normalizePositions(window.id, sourceSpaceId);
}
// Move all tabs in the group to the new space
for (const targetTab of targetTabs) {
targetTab.setSpace(spaceId);
targetTab.setWindow(window);

tabsController.setActiveTab(tab);
return true;
});
if (newPosition !== undefined) {
targetTab.updateStateProperty("position", newPosition);
}
}

// Normalize positions in the target space
tabsController.normalizePositions(window.id, spaceId);

// Normalize positions in the source space (may be in a different window for cross-window moves)
if (sourceSpaceId !== spaceId || sourceWindowId !== window.id) {
const normalizeWindowId = sourceWindowId ?? window.id;
tabsController.normalizePositions(normalizeWindowId, sourceSpaceId);
}

tabsController.setActiveTab(tab);
return true;
}
);

ipcMain.on("tabs:show-context-menu", (event, tabId: number) => {
const webContents = event.sender;
Expand Down
8 changes: 6 additions & 2 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,12 @@ const tabsAPI: FlowTabsAPI = {
return ipcRenderer.invoke("tabs:move-tab", tabId, newPosition);
},

moveTabToWindowSpace: async (tabId: number, spaceId: string, newPosition?: number) => {
return ipcRenderer.invoke("tabs:move-tab-to-window-space", tabId, spaceId, newPosition);
moveTabToWindowSpace: async (tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => {
return ipcRenderer.invoke("tabs:move-tab-to-window-space", tabId, spaceId, newPosition, dragToken);
},

registerDragToken: (token: string, tabId: number) => {
ipcRenderer.send("drag:register-token", token, tabId);
},

// Special Exception: This is allowed for all internal protocols.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@ import { cn } from "@/lib/utils";
import { useSpaces } from "@/components/providers/spaces-provider";
import { SpaceIcon } from "@/lib/phosphor-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group";
import {
type TabGroupSourceData,
canDropExternalTabGroup,
canDropElementTabGroup,
parseExternalTabGroupDrop
} from "@/lib/tab-drag-mime";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { AnimatePresence, motion } from "motion/react";

// Layout constants (px)
Expand Down Expand Up @@ -64,35 +71,57 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) {
removeDraggingTimeout();
}

return dropTargetForElements({
element,
canDrop: (args) => {
const sourceData = args.source.data as TabGroupSourceData;
if (sourceData.type !== "tab-group") return false;

const sourceProfileId = sourceData.profileId;
const targetProfileId = space.profileId;

// Does not support moving tabs between profiles
if (sourceProfileId !== targetProfileId) return false;

// Don't allow dropping on the space the tab is already in
if (sourceData.spaceId === space.id) return false;

return true;
},
onDragEnter: startDragging,
onDrag: startDragging,
onDragLeave: stopDragging,
onDrop: (args) => {
stopDragging();

// Move the tab to this space (no specific position — append to end)
const sourceData = args.source.data as TabGroupSourceData;
const sourceTabId = sourceData.primaryTabId;
flow.tabs.moveTabToWindowSpace(sourceTabId, space.id);
function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) {
stopDragging();

// Validate profile compatibility
if (sourceData.profileId !== space.profileId) {
// TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet
return;
}
});

// For external (cross-window) drops, always move via IPC even if same space
if (!isExternal && sourceData.spaceId === space.id) {
return;
}

// Move the tab to this space (no specific position — append to end)
const sourceTabId = sourceData.primaryTabId;
flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken);
}

return combine(
dropTargetForElements({
element,
canDrop: (args) =>
canDropElementTabGroup(args.source.data, {
profileId: space.profileId,
excludeSpaceId: space.id
}),
onDragEnter: startDragging,
onDrag: startDragging,
onDragLeave: stopDragging,
onDrop: (args) => {
const sourceData = args.source.data as TabGroupSourceData;
handleDrop(sourceData, false);
}
}),

dropTargetForExternal({
element,
canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId),
onDragEnter: startDragging,
onDrag: startDragging,
onDragLeave: stopDragging,
onDrop: (args) => {
stopDragging();

const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}
Comment on lines +116 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopDragging() called twice for external drops

stopDragging() is called on line 117 before handleDrop is invoked, and then again as the very first statement inside handleDrop (line 75). The double-call is harmless (idempotent), but it's inconsistent with the internal onDrop path (which calls handleDrop directly without a preceding stopDragging()). Removing the redundant call makes the two paths symmetrical:

Suggested change
onDrop: (args) => {
stopDragging();
const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}
onDrop: (args) => {
const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}

The same pattern occurs in src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx at lines 111–117.

})
);
}, [onClick, removeDraggingTimeout, space.profileId, space.id]);

const showIcon = !compact || isHovered || isActive;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group";
import {
TabGroupSourceData,
canDropExternalTabGroup,
canDropElementTabGroup,
parseExternalTabGroupDrop
} from "@/lib/tab-drag-mime";
import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator";
import { useEffect, useRef, useState } from "react";
import { Space } from "~/flow/interfaces/sessions/spaces";
import {
dropTargetForElements,
ElementDropTargetEventBasePayload
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
dropTargetForExternal,
ExternalDropTargetEventBasePayload
} from "@atlaskit/pragmatic-drag-and-drop/external/adapter";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";

type TabDropTargetProps = {
spaceData: Space;
Expand All @@ -26,49 +36,59 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex }
const el = dropTargetRef.current;
if (!el) return () => {};

function onDrop(args: ElementDropTargetEventBasePayload) {
function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) {
setShowDropIndicator(false);

const sourceData = args.source.data as TabGroupSourceData;
const sourceTabId = sourceData.primaryTabId;

const newPos = biggestIndex + 1;

if (sourceData.spaceId !== spaceData.id) {
if (sourceData.spaceId !== spaceData.id || isExternal) {
if (sourceData.profileId !== spaceData.profileId) {
// TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet
} else {
flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos);
flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos, sourceData.dragToken);
}
Comment on lines +49 to 50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passes drag credential unconditionally; inconsistent with tab-group.tsx

handleDrop always passes the drag credential from sourceData to moveTabToWindowSpace, for both internal and external drops. This is safe today because getInitialData (used for internal drags) does not populate the credential field, leaving it as undefined, and the IPC handler skips validation when no credential is supplied.

tab-group.tsx takes the more explicit approach, passing undefined for internal drops and only forwarding the credential when isExternal is true. Aligning this file with that pattern makes the design intent self-documenting and prevents a future regression if internal drag payloads ever accidentally gain a credential field.

The same concern applies to src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx at line 50.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

} else {
moveTab(sourceTabId, newPos);
}
}

function onDrop(args: ElementDropTargetEventBasePayload) {
const sourceData = args.source.data as TabGroupSourceData;
handleDrop(sourceData, false);
}

function onExternalDrop(args: ExternalDropTargetEventBasePayload) {
setShowDropIndicator(false);

const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}

function onChange() {
setShowDropIndicator(true);
}

const cleanupDropTarget = dropTargetForElements({
element: el,
canDrop: (args) => {
const sourceData = args.source.data as TabGroupSourceData;
if (sourceData.type !== "tab-group") {
return false;
}
if (sourceData.profileId !== spaceData.profileId) {
// TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet
return false;
}
return true;
},
onDrop: onDrop,
onDragEnter: onChange,
onDrag: onChange,
onDragLeave: () => setShowDropIndicator(false)
});
return combine(
dropTargetForElements({
element: el,
canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }),
onDrop: onDrop,
onDragEnter: onChange,
onDrag: onChange,
onDragLeave: () => setShowDropIndicator(false)
}),

return cleanupDropTarget;
dropTargetForExternal({
element: el,
canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId),
onDrop: onExternalDrop,
onDragEnter: onChange,
onDrag: onChange,
onDragLeave: () => setShowDropIndicator(false)
})
);
}, [spaceData.profileId, isSpaceLight, moveTab, biggestIndex, spaceData.id]);

return (
Expand Down
Loading