diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx index e25f35c91..dbc39b4c0 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -74,7 +74,7 @@ const AppRenderer = ({ const handleOpenLink = async ({ url }: { url: string }) => { let isError = true; if (url.startsWith("https://") || url.startsWith("http://")) { - window.open(url, "_blank"); + window.open(url, "_blank", "noopener,noreferrer"); isError = false; } return { isError }; diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index 7d9ae060c..4695a9cb7 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -24,8 +24,13 @@ type MockMcpUiRendererProps = { params: { role: "user"; content: { type: "text"; text: string }[] }, extra: RequestHandlerExtra, ) => Promise; + onOpenLink?: (params: { url: string }) => Promise<{ isError: boolean }>; }; +// Set by the mocked renderer's "trigger-open-link" button so a test can assert +// on what the host's onOpenLink handler returned. +let lastOpenLinkResult: { isError: boolean } | undefined; + const mockBridgeEvents: BridgeEvent[] = []; // Mock the ext-apps module @@ -55,6 +60,7 @@ jest.mock("@mcp-ui/client", () => { toolInput, toolResult, onMessage, + onOpenLink, }: MockMcpUiRendererProps) => { const [isInitialized, setIsInitialized] = React.useState(false); @@ -108,6 +114,16 @@ jest.mock("@mcp-ui/client", () => { > Trigger Message + ); }, @@ -144,6 +160,8 @@ describe("AppRenderer", () => { beforeEach(() => { jest.clearAllMocks(); mockBridgeEvents.length = 0; + lastOpenLinkResult = undefined; + delete document.body.dataset.openLinkUrl; }); it("should display waiting state when mcpClient is null", () => { @@ -175,6 +193,51 @@ describe("AppRenderer", () => { }); }); + describe("onOpenLink", () => { + // Regression guard for reverse tabnabbing: a window opened without + // "noopener" hands the app's page a live `window.opener` reference, which + // it can use to navigate the Inspector tab to a page of its choosing. + it("should open external links with noopener,noreferrer", async () => { + const openSpy = jest + .spyOn(window, "open") + .mockReturnValue(null as unknown as Window); + + render(); + document.body.dataset.openLinkUrl = "https://example.com/docs"; + + fireEvent.click(screen.getByTestId("trigger-open-link")); + + await waitFor(() => { + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/docs", + "_blank", + "noopener,noreferrer", + ); + expect(lastOpenLinkResult).toEqual({ isError: false }); + }); + + openSpy.mockRestore(); + }); + + it("should refuse to open a non-http(s) url", async () => { + const openSpy = jest + .spyOn(window, "open") + .mockReturnValue(null as unknown as Window); + + render(); + document.body.dataset.openLinkUrl = "javascript:alert(1)"; + + fireEvent.click(screen.getByTestId("trigger-open-link")); + + await waitFor(() => { + expect(lastOpenLinkResult).toEqual({ isError: true }); + }); + expect(openSpy).not.toHaveBeenCalled(); + + openSpy.mockRestore(); + }); + }); + it("should send provided tool input and tool result after app initialization", async () => { const result: CompatibilityCallToolResult = { content: [{ type: "text", text: "Budget initialized" }],