From a0ee09e66ac67dd7ab4038d9605f6acbe928484e Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Sat, 24 Jan 2026 22:54:13 +0900 Subject: [PATCH 01/56] feat: hide the reset zoom button when at the initial zoom level instead of disabling it. --- .../manager/components/preview/tools/zoom.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index d6dfc5307929..b9d2883a3a2e 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -81,15 +81,15 @@ export const Zoom = memo<{ } after={ - zoomTo(INITIAL_ZOOM_LEVEL)} - ariaLabel="Reset zoom" - > - - + zoomTo(INITIAL_ZOOM_LEVEL)} + ariaLabel="Reset zoom" + > + + } value={`${Math.round(value * 100)}%`} minValue={1} From b04ffe240a8a2a0459f2205cfa0e2a405c5f4bd2 Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Sun, 25 Jan 2026 14:20:57 +0900 Subject: [PATCH 02/56] refactor: adjust whitespace in zoom reset button style property --- .../manager/components/preview/tools/zoom.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index b9d2883a3a2e..1748d1ac86ab 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -81,15 +81,15 @@ export const Zoom = memo<{ } after={ - zoomTo(INITIAL_ZOOM_LEVEL)} - ariaLabel="Reset zoom" - > - - + zoomTo(INITIAL_ZOOM_LEVEL)} + ariaLabel="Reset zoom" + > + + } value={`${Math.round(value * 100)}%`} minValue={1} From bbd480ee2f4a8ac388847cfae3a05c91377c226f Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Mon, 26 Jan 2026 23:48:54 +0900 Subject: [PATCH 03/56] refactor: Conditionally render the zoom reset button instead of toggling its visibility. --- .../manager/components/preview/tools/zoom.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 1748d1ac86ab..2ca48a5c5efb 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -81,15 +81,16 @@ export const Zoom = memo<{ } after={ - zoomTo(INITIAL_ZOOM_LEVEL)} - ariaLabel="Reset zoom" - > - - + value !== INITIAL_ZOOM_LEVEL && ( + zoomTo(INITIAL_ZOOM_LEVEL)} + ariaLabel="Reset zoom" + > + + + ) } value={`${Math.round(value * 100)}%`} minValue={1} From 7121f749ef26fbf0543654affaf2dc5c03dbf749 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Thu, 29 Jan 2026 15:37:46 +0500 Subject: [PATCH 04/56] Fix Edit JSON button accessibility on small screens (WCAG 2.1 Reflow) --- .../addons/docs/src/blocks/controls/Object.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index a817cd1e368a..a6a64ef06e39 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Form, ToggleButton } from 'storybook/internal/components'; -import { AddIcon, SubtractIcon } from '@storybook/icons'; +import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; import { type Theme, styled, useTheme } from 'storybook/theming'; @@ -19,6 +19,7 @@ type JsonTreeProps = ComponentProps; const Wrapper = styled.div(({ theme }) => ({ position: 'relative', display: 'flex', + flexWrap: 'wrap', isolation: 'isolate', '.rejt-tree': { @@ -118,10 +119,14 @@ const Input = styled.input(({ theme, placeholder }) => ({ })); const RawButton = styled(ToggleButton)({ - position: 'absolute', - zIndex: 2, - top: 2, - right: 2, + flexShrink: 0, + + // Hide text on small screens, show only icon for accessibility (WCAG 2.1 Reflow) + '@media (max-width: 400px)': { + '& > span': { + display: 'none', + }, + }, }); const RawInput = styled(Form.Textarea)(({ theme }) => ({ @@ -256,7 +261,8 @@ export const ObjectControl: FC = ({ name, value, onChange, argType setShowRaw((isRaw) => !isRaw); }} > - Edit JSON + + Edit JSON )} {!showRaw ? ( From e5efb51799499275e05b94faf2c2da1fac614609 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Thu, 29 Jan 2026 15:50:32 +0500 Subject: [PATCH 05/56] Add gap between icon and text in Edit JSON button --- code/addons/docs/src/blocks/controls/Object.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index a6a64ef06e39..03218c27b5dc 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -120,6 +120,7 @@ const Input = styled.input(({ theme, placeholder }) => ({ const RawButton = styled(ToggleButton)({ flexShrink: 0, + gap: '4px', // Hide text on small screens, show only icon for accessibility (WCAG 2.1 Reflow) '@media (max-width: 400px)': { From 2c3c4bddd8eb7d8fb7359baa7df950ec0bed4509 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 10 Feb 2026 15:05:04 +0500 Subject: [PATCH 06/56] fix: restore absolute positioning for Edit JSON button on large screens, use column layout on small screens for WCAG 2.1 Reflow --- code/addons/docs/src/blocks/controls/Object.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index 03218c27b5dc..8e9d0b3a5c46 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -19,9 +19,12 @@ type JsonTreeProps = ComponentProps; const Wrapper = styled.div(({ theme }) => ({ position: 'relative', display: 'flex', - flexWrap: 'wrap', isolation: 'isolate', + '@media (max-width: 400px)': { + flexDirection: 'column' as const, + }, + '.rejt-tree': { marginLeft: '1rem', fontSize: '13px', @@ -119,11 +122,16 @@ const Input = styled.input(({ theme, placeholder }) => ({ })); const RawButton = styled(ToggleButton)({ - flexShrink: 0, + position: 'absolute', + zIndex: 2, + top: 2, + right: 2, gap: '4px', - // Hide text on small screens, show only icon for accessibility (WCAG 2.1 Reflow) + // On small screens: remove absolute positioning, show only icon (WCAG 2.1 Reflow) '@media (max-width: 400px)': { + position: 'static', + alignSelf: 'flex-end', '& > span': { display: 'none', }, From 310a764cb72752fa51170f7c45aae0deee1c3ebc Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 10 Feb 2026 17:36:19 +0500 Subject: [PATCH 07/56] Add small viewport stories for Chromatic coverage --- .../src/blocks/controls/Object.stories.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/code/addons/docs/src/blocks/controls/Object.stories.tsx b/code/addons/docs/src/blocks/controls/Object.stories.tsx index de883570198b..50146d080f31 100644 --- a/code/addons/docs/src/blocks/controls/Object.stories.tsx +++ b/code/addons/docs/src/blocks/controls/Object.stories.tsx @@ -112,3 +112,31 @@ export const ReadonlyAndUndefined: Story = { argType: { table: { readonly: true } }, }, }; + +export const ObjectSmallViewport: Story = { + args: { + value: { + name: 'Michael', + someDate: new Date('2022-10-30T12:31:11'), + nested: { someBool: true, someNumber: 22 }, + }, + }, + parameters: { + chromatic: { viewports: [320] }, + }, +}; + +export const ArraySmallViewport: Story = { + args: { + value: [ + 'someString', + 22, + true, + new Date('2022-10-30T12:31:11'), + { someBool: true, someNumber: 22 }, + ], + }, + parameters: { + chromatic: { viewports: [320] }, + }, +}; From fba3173a79fe837862cc6e52bc17abc2536c9d09 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 11 Feb 2026 20:48:27 +0100 Subject: [PATCH 08/56] Add origin validation to websocket connections - Updated `dev-server.ts` to expose local and network addresses for QR code link sharing. - Refactored `get-server-channel.ts` to accept options and validate WebSocket origins. - Removed the deprecated `validate-websocket-token.ts` and integrated its functionality into `validate-websocket.ts`. - Enhanced tests in `server-channel.test.ts` and added new tests in `validate-websocket.test.ts` to cover origin and token validation scenarios. This change enhances security and usability for WebSocket connections. --- code/core/src/core-server/dev-server.ts | 19 +- .../utils/__tests__/server-channel.test.ts | 183 +++++++++++++++++- .../__tests__/validate-websocket.test.ts | 133 +++++++++++++ .../core-server/utils/get-server-channel.ts | 44 +++-- .../utils/validate-websocket-token.ts | 20 -- .../core-server/utils/validate-websocket.ts | 47 +++++ code/core/src/types/modules/core-common.ts | 1 + 7 files changed, 391 insertions(+), 56 deletions(-) create mode 100644 code/core/src/core-server/utils/__tests__/validate-websocket.test.ts delete mode 100644 code/core/src/core-server/utils/validate-websocket-token.ts create mode 100644 code/core/src/core-server/utils/validate-websocket.ts diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 031d2d4d4768..9d5a5d1f5796 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -28,9 +28,18 @@ export async function storybookDevServer(options: Options) { const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]); const app = polka({ server }); + const { port, host, initialPath } = options; + invariant(port, 'expected options to have a port'); + const proto = options.https ? 'https' : 'http'; + const { address, networkAddress } = getServerAddresses(port, host, proto, initialPath); + + // Expose addresses on options for the manager builder to surface in globals, important for QR code link sharing + options.localAddress = address; + options.networkAddress = networkAddress; + const serverChannel = await options.presets.apply( 'experimental_serverChannel', - getServerChannel(server, core?.channelOptions?.wsToken) + getServerChannel(server, options, core?.channelOptions?.wsToken) ); const workingDir = process.cwd(); @@ -69,14 +78,6 @@ export async function storybookDevServer(options: Options) { // Apply experimental_devServer preset to allow addons/frameworks to extend the dev server with middlewares, etc. await options.presets.apply('experimental_devServer', app); - const { port, host, initialPath } = options; - invariant(port, 'expected options to have a port'); - const proto = options.https ? 'https' : 'http'; - const { address, networkAddress } = getServerAddresses(port, host, proto, initialPath); - - // Expose addresses on options for the manager builder to surface in globals, important for QR code link sharing - options.networkAddress = networkAddress; - if (!core?.builder) { throw new MissingBuilderError(); } diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index fe17cd1f6185..87683d11369e 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel } from 'storybook/internal/channels'; @@ -8,16 +8,21 @@ import { stringify } from 'telejson'; import { ServerChannelTransport, getServerChannel } from '../get-server-channel'; +const options = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', +} as any; + describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; - const result = getServerChannel(server, 'test-token-123'); + const result = getServerChannel(server, options, 'test-token-123'); expect(result).toBeInstanceOf(Channel); }); it('should attach to the http server', () => { const server = { on: vi.fn() } as any as Server; - getServerChannel(server, 'test-token-123'); + getServerChannel(server, options, 'test-token-123'); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); @@ -25,10 +30,18 @@ describe('getServerChannel', () => { describe('ServerChannelTransport', () => { const mockToken = 'test-token-123'; + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('parses simple JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -42,7 +55,7 @@ describe('ServerChannelTransport', () => { it('parses object JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -56,7 +69,7 @@ describe('ServerChannelTransport', () => { it('supports telejson cyclical data', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -81,11 +94,14 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); // Simulate upgrade request without token const request = { url: '/storybook-server-channel', + headers: { + origin: 'http://localhost:6006', + }, } as any; const head = Buffer.from(''); @@ -104,11 +120,14 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); // Simulate upgrade request with wrong token const request = { url: '/storybook-server-channel?token=wrong-token', + headers: { + origin: 'http://localhost:6006', + }, } as any; const head = Buffer.from(''); @@ -128,15 +147,98 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options, mockToken); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request with correct token and valid origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://localhost:6006', + }, + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); + + it('rejects connections with invalid origin', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const transport = new ServerChannelTransport(server, options, mockToken); + + // Simulate upgrade request with invalid origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://malicious-site.com', + }, + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).toHaveBeenCalledWith( + 'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n' + ); + expect(destroySpy).toHaveBeenCalled(); + }); + + it('rejects connections without origin header', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const transport = new ServerChannelTransport(server, options, mockToken); + + // Simulate upgrade request without origin header + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: {}, + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).toHaveBeenCalledWith( + 'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n' + ); + expect(destroySpy).toHaveBeenCalled(); + }); + + it('accepts connections with network address origin', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const handleUpgradeSpy = vi.fn(); + const transport = new ServerChannelTransport(server, options, mockToken); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) transport.socket.handleUpgrade = handleUpgradeSpy; - // Simulate upgrade request with correct token + // Simulate upgrade request with network address origin const request = { url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://192.168.1.100:6006', + }, } as any; const head = Buffer.from(''); @@ -147,4 +249,65 @@ describe('ServerChannelTransport', () => { expect(destroySpy).not.toHaveBeenCalled(); expect(handleUpgradeSpy).toHaveBeenCalled(); }); + + it('accepts connections with 127.0.0.1 origin', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const handleUpgradeSpy = vi.fn(); + const transport = new ServerChannelTransport(server, options, mockToken); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request with 127.0.0.1 origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://127.0.0.1:6006', + }, + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); + + it('rejects connections to wrong path', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const handleUpgradeSpy = vi.fn(); + const transport = new ServerChannelTransport(server, options, mockToken); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request to wrong path + const request = { + url: `/wrong-path?token=${mockToken}`, + headers: { + origin: 'http://localhost:6006', + }, + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + // Should not call handleUpgrade for wrong path + expect(handleUpgradeSpy).not.toHaveBeenCalled(); + // Socket should not be destroyed for wrong path (just ignored) + expect(destroySpy).not.toHaveBeenCalled(); + }); }); diff --git a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts new file mode 100644 index 000000000000..951f1157c1f4 --- /dev/null +++ b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { isValidOrigin, isValidToken } from '../validate-websocket'; + +describe('isValidOrigin', () => { + const options = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + } as any; + + it('returns true for exact local address match', () => { + expect(isValidOrigin('http://localhost:6006', options)).toBe(true); + }); + + it('returns true for network address match', () => { + expect(isValidOrigin('http://192.168.1.100:6006', options)).toBe(true); + }); + + it('returns true for localhost origin', () => { + expect(isValidOrigin('http://localhost:6006', options)).toBe(true); + }); + + it('returns true for 127.0.0.1 origin', () => { + expect(isValidOrigin('http://127.0.0.1:6006', options)).toBe(true); + }); + + it('returns false for different origin', () => { + expect(isValidOrigin('http://malicious-site.com', options)).toBe(false); + }); + + it('returns false for different port', () => { + expect(isValidOrigin('http://localhost:8080', options)).toBe(false); + }); + + it('returns false for different protocol', () => { + expect(isValidOrigin('https://localhost:6006', options)).toBe(false); + }); + + it('returns false for undefined origin', () => { + expect(isValidOrigin(undefined, options)).toBe(false); + }); + + it('returns false for null origin', () => { + expect(isValidOrigin(null as any, options)).toBe(false); + }); + + it('returns false when localAddress is missing', () => { + const optionsWithoutLocal = { + networkAddress: 'http://192.168.1.100:6006', + } as any; + expect(isValidOrigin('http://localhost:6006', optionsWithoutLocal)).toBe(false); + }); + + it('handles https protocol correctly', () => { + const httpsOptions = { + localAddress: 'https://localhost:6006', + networkAddress: 'https://192.168.1.100:6006', + } as any; + expect(isValidOrigin('https://localhost:6006', httpsOptions)).toBe(true); + expect(isValidOrigin('https://192.168.1.100:6006', httpsOptions)).toBe(true); + expect(isValidOrigin('http://localhost:6006', httpsOptions)).toBe(false); + }); + + it('handles network address without port', () => { + const optionsWithoutNetwork = { + localAddress: 'http://localhost:6006', + } as any; + expect(isValidOrigin('http://localhost:6006', optionsWithoutNetwork)).toBe(true); + expect(isValidOrigin('http://192.168.1.100:6006', optionsWithoutNetwork)).toBe(false); + }); + + it('handles different network address correctly', () => { + expect(isValidOrigin('http://10.0.0.1:6006', options)).toBe(false); + }); + + it('handles origin with path', () => { + // Origin header should not include path, but if it does, we should still validate correctly + expect(isValidOrigin('http://localhost:6006/path', options)).toBe(true); + }); + + it('handles origin with query parameters', () => { + // Origin header should not include query, but if it does, we should still validate correctly + expect(isValidOrigin('http://localhost:6006?query=value', options)).toBe(true); + }); +}); + +describe('isValidToken', () => { + const validToken = 'test-token-123'; + + it('returns true for matching tokens', () => { + expect(isValidToken(validToken, validToken)).toBe(true); + }); + + it('returns false for non-matching tokens', () => { + expect(isValidToken('wrong-token', validToken)).toBe(false); + }); + + it('returns false for null token', () => { + expect(isValidToken(null, validToken)).toBe(false); + }); + + it('returns false for undefined token', () => { + expect(isValidToken(undefined as any, validToken)).toBe(false); + }); + + it('returns false for empty token', () => { + expect(isValidToken('', validToken)).toBe(false); + }); + + it('returns false for null expected token', () => { + expect(isValidToken(validToken, null as any)).toBe(false); + }); + + it('returns false for empty expected token', () => { + expect(isValidToken(validToken, '')).toBe(false); + }); + + it('returns false for tokens with different lengths', () => { + expect(isValidToken('short', 'much-longer-token')).toBe(false); + }); + + it('handles special characters in tokens', () => { + const specialToken = 'token-with-special-chars-!@#$%^&*()'; + expect(isValidToken(specialToken, specialToken)).toBe(true); + expect(isValidToken(specialToken, 'different-token')).toBe(false); + }); + + it('handles unicode characters in tokens', () => { + const unicodeToken = 'token-with-unicode-๐Ÿš€-ๆต‹่ฏ•'; + expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); + expect(isValidToken(unicodeToken, 'different-token')).toBe(false); + }); +}); diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 5ff31bcb296e..e3226e820cce 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -2,12 +2,13 @@ import type { IncomingMessage } from 'node:http'; import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; +import type { Options } from 'storybook/internal/types'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; -import { isValidToken } from './validate-websocket-token'; +import { isValidOrigin, isValidToken } from './validate-websocket'; type Server = NonNullable[0]>['server']>; @@ -24,27 +25,36 @@ export class ServerChannelTransport { private token: string; - constructor(server: Server, token: string) { + constructor(server: Server, options: Options, token: string) { this.token = token; this.socket = new WebSocketServer({ noServer: true }); server.on('upgrade', (request: IncomingMessage, socket, head) => { - if (request.url) { - const url = new URL(request.url, 'http://localhost'); - if (url.pathname === '/storybook-server-channel') { - const requestToken = url.searchParams.get('token'); - if (!isValidToken(requestToken, this.token)) { - socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); - socket.destroy(); - return; - } - - this.socket.handleUpgrade(request, socket, head, (ws) => { - this.socket.emit('connection', ws, request); - }); + try { + const url = request.url && new URL(request.url, request.headers.origin); + if (!url || url.pathname !== '/storybook-server-channel') { + return; } + + if (!isValidOrigin(request.headers.origin, options)) { + throw new Error('Invalid websocket origin'); + } + + const requestToken = url.searchParams.get('token'); + if (!isValidToken(requestToken, this.token)) { + throw new Error('Invalid websocket token'); + } + + this.socket.handleUpgrade(request, socket, head, (ws) => { + this.socket.emit('connection', ws, request); + }); + } catch (error) { + console.warn('Rejecting WebSocket connection:', error); + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); } }); + this.socket.on('connection', (wss) => { wss.on('message', (raw) => { const data = raw.toString(); @@ -84,8 +94,8 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server, token: string) { - const transports = [new ServerChannelTransport(server, token)]; +export function getServerChannel(server: Server, options: Options, token: string) { + const transports = [new ServerChannelTransport(server, options, token)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/validate-websocket-token.ts b/code/core/src/core-server/utils/validate-websocket-token.ts deleted file mode 100644 index ec4ff5796cc1..000000000000 --- a/code/core/src/core-server/utils/validate-websocket-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { timingSafeEqual } from 'node:crypto'; - -/** - * Validates a secret token using constant-time comparison to prevent timing attacks. - * - * @returns `true` if tokens match, `false` otherwise - */ -export function isValidToken(token: string | null, expectedToken: string): boolean { - if (!token || !expectedToken) { - return false; - } - - const a = Buffer.from(token, 'utf8'); - const b = Buffer.from(expectedToken, 'utf8'); - try { - return a.length === b.length && timingSafeEqual(a, b); - } catch { - return false; - } -} diff --git a/code/core/src/core-server/utils/validate-websocket.ts b/code/core/src/core-server/utils/validate-websocket.ts new file mode 100644 index 000000000000..da0e1f25d4d2 --- /dev/null +++ b/code/core/src/core-server/utils/validate-websocket.ts @@ -0,0 +1,47 @@ +import { timingSafeEqual } from 'node:crypto'; + +import type { Options } from 'storybook/internal/types'; + +export const isValidOrigin = ( + requestOrigin: string | undefined, + { localAddress, networkAddress }: Options +): boolean => { + if (!requestOrigin || !localAddress) { + return false; + } + + const localUrl = new URL(localAddress); + const requestUrl = new URL(requestOrigin); + if (localUrl.origin === requestUrl.origin) { + return true; + } + + const networkUrl = networkAddress && new URL(networkAddress); + if (networkUrl && networkUrl.origin === requestUrl.origin) { + return true; + } + + return ( + requestOrigin === `${localUrl.protocol}//localhost:${localUrl.port}` || + requestOrigin === `${localUrl.protocol}//127.0.0.1:${localUrl.port}` + ); +}; + +/** + * Validates a secret token using constant-time comparison to prevent timing attacks. + * + * @returns `true` if tokens match, `false` otherwise + */ +export function isValidToken(token: string | null, expectedToken: string): boolean { + if (!token || !expectedToken) { + return false; + } + + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(expectedToken, 'utf8'); + try { + return a.length === b.length && timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d7422c13469f..c55edbde4eab 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -215,6 +215,7 @@ export interface BuilderOptions { versionCheck?: VersionCheck; disableWebpackDefaults?: boolean; serverChannelUrl?: string; + localAddress?: string; networkAddress?: string; } From 8ec43fd143c879c8919ea8295e12403c461b4ce2 Mon Sep 17 00:00:00 2001 From: Vanessa Yuen <6842965+vanessayuenn@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:28:03 +0100 Subject: [PATCH 09/56] Update supported versions and security patching details Clarified security patching policy for supported versions and updated example version. --- docs/releases/index.mdx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/releases/index.mdx b/docs/releases/index.mdx index f371a2fd290b..06a42122c19a 100644 --- a/docs/releases/index.mdx +++ b/docs/releases/index.mdx @@ -25,15 +25,18 @@ npm create storybook@next ## Supported Versions -We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly across the current major version, and in rare cases (such as for a short period immediately following a new major), to the previous major. - -For example, if the latest version is `9.2.1`: - -- We support `9.x.x` versions and release `9.2.x` patch versions -- Most fixes and new work will be released as `9.3.0-alpha.x` versions - - If the next release is a major version, it would be `10.0.0-alpha.x` -- We will backport critical security fixes to `9.1.x` or `9.0.x` -- Rarely, we may backport critical fixes to `8.6.x` as necessary +We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly based on severity: +- Latest major: Receives all security fixes +- Previous two majors: Receive security patches for **High or Critical CVSS vulnerabilities only** +- Older versions: No longer recieves any patches + +For example, if the latest version is `10.2.1`: + +- We support `10.x.x` versions and release `10.2.x` patch versions +- Most fixes and new work will be released as `10.3.0-alpha.x` versions + - If the next release is a major version, it would be `11.0.0-alpha.x` +- We will backport **High or Critical** security fixes to the latest minor of `9.x.x` and `8.x.x` +- Versions `7.x.x` and older will not receive security patches For compatibility with other libraries and tools in the JavaScript ecosystem, please refer to the [compatibility tracker](https://github.com/storybookjs/storybook/issues/23279). From 4025b7b8dda7413716bce5acae8d72efc1651a1e Mon Sep 17 00:00:00 2001 From: Vanessa Yuen <6842965+vanessayuenn@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:32:01 +0100 Subject: [PATCH 10/56] Update security policy for version support and backporting Clarified the policy on backporting security fixes and specified supported versions based on CVSS scores. --- SECURITY.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 1e3ad889e024..d34652bb0f0a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,13 @@ ## Supported Versions -We release patches for fixing security vulnerabilities, primarily focusing on the latest release only. +We release patches for security vulnerabilities, primarily focusing on the latest major version. -In the event of a high-risk vulnerability, we may backport the security fixes to the minor versions of the software, starting from the latest minor version up to the latest major release. The decision to backport security fixes to older versions will be made based on a risk assessment and the feasibility of implementing the patch in those versions. +Security fixes are backported to the previous two major versions only for vulnerabilities with High or Critical CVSS scores (7.0+). The decision to backport is made based on severity assessment and the feasibility of implementing the patch in those versions. + +- Latest major version: All security vulnerabilities +- Previous two major versions: High or Critical CVSS scores only +- Older versions: Not supported (Users should upgrade to a supported version) ## Reporting a Vulnerability From f45870f3c8302fde16bf07b5b5bd7f84e594a82e Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Fri, 20 Feb 2026 11:01:31 +0500 Subject: [PATCH 11/56] refactor: use sr-only styles instead of display:none and add container queries --- .../docs/src/blocks/controls/Object.tsx | 20 ++++++++++---- code/core/src/manager/globals/exports.ts | 1 + code/core/src/theming/global.ts | 26 ++++++++++--------- code/core/src/theming/index.ts | 2 +- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index 8e9d0b3a5c46..bf67b74a0b3b 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { type Theme, styled, useTheme } from 'storybook/theming'; +import { type Theme, styled, useTheme, srOnlyStyles } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; @@ -21,6 +21,11 @@ const Wrapper = styled.div(({ theme }) => ({ display: 'flex', isolation: 'isolate', + // Enable container queries for child responsive styles + '@supports (container-type: inline-size)': { + containerType: 'inline-size', + }, + '@media (max-width: 400px)': { flexDirection: 'column' as const, }, @@ -128,13 +133,18 @@ const RawButton = styled(ToggleButton)({ right: 2, gap: '4px', - // On small screens: remove absolute positioning, show only icon (WCAG 2.1 Reflow) + // Container query: respond to component width (WCAG 2.1 Reflow) + '@container (max-width: 400px)': { + position: 'static', + alignSelf: 'flex-end', + '& > span': srOnlyStyles, + }, + + // Fallback for browsers without container query support '@media (max-width: 400px)': { position: 'static', alignSelf: 'flex-end', - '& > span': { - display: 'none', - }, + '& > span': srOnlyStyles, }, }); diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 59703619f1de..1cecea844ec2 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -380,6 +380,7 @@ export default { 'jsx', 'keyframes', 'lighten', + 'srOnlyStyles', 'styled', 'themes', 'tokens', diff --git a/code/core/src/theming/global.ts b/code/core/src/theming/global.ts index ba0f089b1603..3b3d69f9d54c 100644 --- a/code/core/src/theming/global.ts +++ b/code/core/src/theming/global.ts @@ -9,6 +9,19 @@ interface Return { }; } +export const srOnlyStyles = { + position: 'absolute' as const, + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + whiteSpace: 'nowrap' as const, + clip: 'rect(0, 0, 0, 0)', + clipPath: 'inset(50%)', + border: 0, +}; + export const createReset = memoize(1)( ({ typography }: { typography: Typography }): Return => ({ body: { @@ -112,18 +125,7 @@ export const createGlobal = memoize(1)(({ borderTop: `1px solid ${color.border}`, }, - '.sb-sr-only, .sb-hidden-until-focus:not(:focus)': { - position: 'absolute', - width: 1, - height: 1, - padding: 0, - margin: -1, - overflow: 'hidden', - whiteSpace: 'nowrap', - clip: 'rect(0, 0, 0, 0)', - clipPath: 'inset(50%)', - border: 0, - }, + '.sb-sr-only, .sb-hidden-until-focus:not(:focus)': srOnlyStyles, '.sb-hidden-until-focus': { opacity: 0, diff --git a/code/core/src/theming/index.ts b/code/core/src/theming/index.ts index 7650c87b0b39..585e4fd5ff35 100644 --- a/code/core/src/theming/index.ts +++ b/code/core/src/theming/index.ts @@ -33,7 +33,7 @@ export * from './types'; export { default as createCache } from '@emotion/cache'; export { default as isPropValid } from '@emotion/is-prop-valid'; -export { createGlobal, createReset } from './global'; +export { createGlobal, createReset, srOnlyStyles } from './global'; export * from './create'; export * from './convert'; export * from './ensure'; From 45eda4401029ee015bd5f80d063a943e72f8ee24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:14:08 +0000 Subject: [PATCH 12/56] Initial plan From f48a1ae6964f82586c20b763960e3cdad2d0d70f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:21:40 +0000 Subject: [PATCH 13/56] Fix ConfigFile parser warning on definePreview({...}).type() chaining Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com> --- code/core/src/csf-tools/ConfigFile.test.ts | 17 +++++++++++++++++ code/core/src/csf-tools/ConfigFile.ts | 13 ++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index f702579aeecd..191989b1751b 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -293,6 +293,23 @@ describe('ConfigFile', () => { ) ).toEqual(['test', 'vitest', '!a11ytest']); }); + it('parses correctly with .type() chaining on export default', () => { + const source = dedent` + import { definePreview } from '@storybook/react-vite'; + + export default definePreview({ + parameters: { + foo: 'bar', + }, + }).type<{ + parameters: { + customParam?: string; + }; + }>(); + `; + const config = loadConfig(source).parse(); + expect(config.getFieldValue(['parameters', 'foo'])).toEqual('bar'); + }); }); }); diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index c5f376f5096f..79aa5a9d886f 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -204,9 +204,16 @@ export class ConfigFile { self.hasDefaultExport = true; let decl = self._resolveDeclaration(node.declaration as t.Node, parent); - // csf factory - if (t.isCallExpression(decl) && t.isObjectExpression(decl.arguments[0])) { - decl = decl.arguments[0]; + // csf factory - unwrap call expressions like definePreview({...}) or definePreview({...}).type() + while (t.isCallExpression(decl)) { + if (t.isObjectExpression(decl.arguments[0])) { + decl = decl.arguments[0]; + break; + } else if (t.isMemberExpression(decl.callee) && t.isCallExpression(decl.callee.object)) { + decl = decl.callee.object; + } else { + break; + } } if (t.isObjectExpression(decl)) { From ed53d500f4d0ec9e01633188f15e726e202fbba7 Mon Sep 17 00:00:00 2001 From: unional Date: Fri, 20 Feb 2026 22:52:47 -0800 Subject: [PATCH 14/56] fix: Update TagOptions to allow undefined defaultFilterSelection and add tests for TagsOptions --- .../src/types/modules/core-common.test.ts | 21 +++++++++++++++++++ code/core/src/types/modules/core-common.ts | 2 +- code/tsconfig.json | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 code/core/src/types/modules/core-common.test.ts diff --git a/code/core/src/types/modules/core-common.test.ts b/code/core/src/types/modules/core-common.test.ts new file mode 100644 index 000000000000..08dba26bba21 --- /dev/null +++ b/code/core/src/types/modules/core-common.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import type { StorybookConfig } from '@storybook/react-vite'; + +describe('TagsOptions', () => { + describe('defaultFilterSelection', () => { + it('accepts undefined', () => { + const config: StorybookConfig = { + stories: [], + framework: '@storybook/react-vite', + tags: { + unit: { + defaultFilterSelection: + process.env['NODE_ENV'] !== 'development' ? 'exclude' : undefined, + }, + }, + }; + expect(config).toBeDefined(); + }); + }); +}); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 433e4e6af96e..99643a3aa644 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -341,7 +341,7 @@ type Tag = string; export interface TagOptions { /** Visually include or exclude stories with this tag in the sidebar by default */ - defaultFilterSelection?: 'include' | 'exclude'; + defaultFilterSelection?: 'include' | 'exclude' | undefined; excludeFromSidebar: boolean; excludeFromDocsStories: boolean; } diff --git a/code/tsconfig.json b/code/tsconfig.json index a0979540cdc8..1c63316a5066 100644 --- a/code/tsconfig.json +++ b/code/tsconfig.json @@ -5,6 +5,7 @@ "baseUrl": ".", "customConditions": ["code"], "esModuleInterop": true, + // "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "ignoreDeprecations": "5.0", "incremental": false, From ed5064ecdf4702f72c760d68a45f31d7016d8151 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 23:14:54 -0800 Subject: [PATCH 15/56] fix(manager-api): correctly resolve iframe.html URL when hosted at a subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Storybook is hosted at a subpath without a trailing slash (e.g. `/design-system`), the previous regex `/\/[^/]*$/` would strip the entire last path segment and replace it with `/`, losing the subpath prefix and producing `/iframe.html` instead of `/design-system/iframe.html`. Fix: only strip the last segment if it looks like an HTML file (ends in `.html`), then ensure a trailing slash before appending `iframe.html`. This correctly handles all cases: - `/` โ†’ `/iframe.html` - `/index.html` โ†’ `/iframe.html` - `/design-system` โ†’ `/design-system/iframe.html` - `/design-system/` โ†’ `/design-system/iframe.html` - `/design-system/index.html` โ†’ `/design-system/iframe.html` Fixes #33848 --- code/core/src/manager-api/modules/url.ts | 3 +- code/core/src/manager-api/tests/url.test.js | 45 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index b7f37e179e59..e46494a34cf4 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -254,7 +254,8 @@ export const init: ModuleFn = (moduleArgs) => { base === 'origin' ? originAddress : base === 'network' ? networkAddress : pathname; const previewBase = refId ? refs[refId].url + '/iframe.html' - : global.PREVIEW_URL || `${managerBase.replace(/\/[^/]*$/, '/')}iframe.html`; + : global.PREVIEW_URL || + `${managerBase.replace(/\/[^/]*\.html$/, '').replace(/\/?$/, '/')}iframe.html`; const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; const { args = '', globals = '', ...otherParams } = queryParams; diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index aedbc591d13e..4b259bdf6599 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -486,4 +486,49 @@ describe('getStoryHrefs', () => { expect(managerHref).toEqual('/index.html?path=/story/test--story'); expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); }); + + it('correctly links when hosted at a subpath without trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with index.html', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/index.html', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/index.html?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); }); From f095cf7438fb9e2dc8c4b2c5f738785c0c24695d Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 24 Feb 2026 09:32:44 +0100 Subject: [PATCH 16/56] Refactor origin validation to accept allowedHosts option and allow access by default (for now) --- .../__tests__/validate-websocket.test.ts | 77 ++++++++++++++++--- .../core-server/utils/validate-websocket.ts | 44 +++++++---- code/core/src/types/modules/core-common.ts | 1 + 3 files changed, 95 insertions(+), 27 deletions(-) diff --git a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts index 951f1157c1f4..a6e54ef5ca5a 100644 --- a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts +++ b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts @@ -8,6 +8,9 @@ describe('isValidOrigin', () => { networkAddress: 'http://192.168.1.100:6006', } as any; + /** When allowedHosts is set to [], only local/network origins are allowed (no default allow-all). */ + const strictOptions = { ...options, allowedHosts: [] } as any; + it('returns true for exact local address match', () => { expect(isValidOrigin('http://localhost:6006', options)).toBe(true); }); @@ -24,29 +27,41 @@ describe('isValidOrigin', () => { expect(isValidOrigin('http://127.0.0.1:6006', options)).toBe(true); }); - it('returns false for different origin', () => { - expect(isValidOrigin('http://malicious-site.com', options)).toBe(false); + it('when allowedHosts is undefined, allows any origin', () => { + expect(isValidOrigin('http://malicious-site.com', options)).toBe(true); + expect(isValidOrigin('https://any-origin.example.com', options)).toBe(true); + }); + + it('when allowedHosts is ["*"], allows any origin', () => { + const allowAllOptions = { ...options, allowedHosts: ['*'] } as any; + expect(isValidOrigin('http://malicious-site.com', allowAllOptions)).toBe(true); + expect(isValidOrigin('https://any-origin.example.com', allowAllOptions)).toBe(true); + }); + + it('returns false for different origin when allowedHosts is empty', () => { + expect(isValidOrigin('http://malicious-site.com', strictOptions)).toBe(false); }); it('returns false for different port', () => { - expect(isValidOrigin('http://localhost:8080', options)).toBe(false); + expect(isValidOrigin('http://localhost:8080', strictOptions)).toBe(false); }); it('returns false for different protocol', () => { - expect(isValidOrigin('https://localhost:6006', options)).toBe(false); + expect(isValidOrigin('https://localhost:6006', strictOptions)).toBe(false); }); - it('returns false for undefined origin', () => { - expect(isValidOrigin(undefined, options)).toBe(false); + it('returns false for undefined origin when not allow-all', () => { + expect(isValidOrigin(undefined, strictOptions)).toBe(false); }); - it('returns false for null origin', () => { - expect(isValidOrigin(null as any, options)).toBe(false); + it('returns false for null origin when not allow-all', () => { + expect(isValidOrigin(null as any, strictOptions)).toBe(false); }); - it('returns false when localAddress is missing', () => { + it('returns false when localAddress is missing and allowedHosts is empty', () => { const optionsWithoutLocal = { networkAddress: 'http://192.168.1.100:6006', + allowedHosts: [], } as any; expect(isValidOrigin('http://localhost:6006', optionsWithoutLocal)).toBe(false); }); @@ -58,7 +73,8 @@ describe('isValidOrigin', () => { } as any; expect(isValidOrigin('https://localhost:6006', httpsOptions)).toBe(true); expect(isValidOrigin('https://192.168.1.100:6006', httpsOptions)).toBe(true); - expect(isValidOrigin('http://localhost:6006', httpsOptions)).toBe(false); + const strictHttpsOptions = { ...httpsOptions, allowedHosts: [] } as any; + expect(isValidOrigin('http://localhost:6006', strictHttpsOptions)).toBe(false); }); it('handles network address without port', () => { @@ -66,11 +82,12 @@ describe('isValidOrigin', () => { localAddress: 'http://localhost:6006', } as any; expect(isValidOrigin('http://localhost:6006', optionsWithoutNetwork)).toBe(true); - expect(isValidOrigin('http://192.168.1.100:6006', optionsWithoutNetwork)).toBe(false); + const strictNoNetwork = { ...optionsWithoutNetwork, allowedHosts: [] } as any; + expect(isValidOrigin('http://192.168.1.100:6006', strictNoNetwork)).toBe(false); }); it('handles different network address correctly', () => { - expect(isValidOrigin('http://10.0.0.1:6006', options)).toBe(false); + expect(isValidOrigin('http://10.0.0.1:6006', strictOptions)).toBe(false); }); it('handles origin with path', () => { @@ -82,6 +99,42 @@ describe('isValidOrigin', () => { // Origin header should not include query, but if it does, we should still validate correctly expect(isValidOrigin('http://localhost:6006?query=value', options)).toBe(true); }); + + it('returns true when request hostname is in allowedHosts', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com', 'other.example.com'], + } as any; + expect(isValidOrigin('https://my-app.example.com', optionsWithAllowed)).toBe(true); + expect(isValidOrigin('https://other.example.com', optionsWithAllowed)).toBe(true); + }); + + it('returns false when request hostname is not in allowedHosts and not local/network', () => { + const optionsWithAllowed = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidOrigin('https://other-site.com', optionsWithAllowed)).toBe(false); + }); + + it('allowedHosts does not override default local/network checks', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidOrigin('http://localhost:6006', optionsWithAllowed)).toBe(true); + expect(isValidOrigin('http://192.168.1.100:6006', optionsWithAllowed)).toBe(true); + }); + + it('empty allowedHosts does not allow arbitrary origins', () => { + const optionsWithEmptyAllowed = { + localAddress: 'http://localhost:6006', + allowedHosts: [], + } as any; + expect(isValidOrigin('http://localhost:6006', optionsWithEmptyAllowed)).toBe(true); + expect(isValidOrigin('https://arbitrary.com', optionsWithEmptyAllowed)).toBe(false); + }); }); describe('isValidToken', () => { diff --git a/code/core/src/core-server/utils/validate-websocket.ts b/code/core/src/core-server/utils/validate-websocket.ts index 7aa1bc6af4d0..cc3196eac695 100644 --- a/code/core/src/core-server/utils/validate-websocket.ts +++ b/code/core/src/core-server/utils/validate-websocket.ts @@ -2,29 +2,43 @@ import { timingSafeEqual } from 'node:crypto'; import type { BuilderOptions } from 'storybook/internal/types'; +// TODO: Change to `[]` in SB11 to change from opt-in to opt-out +const DEFAULT_ALLOWED_HOSTS: string[] = ['*']; + +/** + * Validates a request origin against known local/network addresses and allowed hosts. + * + * @param requestOrigin - The origin header value to validate. + * @param options - The builder options. + * @returns `true` if the origin is valid, `false` otherwise. + */ export const isValidOrigin = ( requestOrigin: string | undefined, - { localAddress, networkAddress }: BuilderOptions + { allowedHosts = DEFAULT_ALLOWED_HOSTS, localAddress, networkAddress }: BuilderOptions ): boolean => { - if (!requestOrigin || !localAddress) { + if (allowedHosts.includes('*')) { + return true; + } + if (!requestOrigin) { return false; } - const localUrl = new URL(localAddress); - const requestUrl = new URL(requestOrigin); - if (localUrl.origin === requestUrl.origin) { - return true; - } + try { + const requestUrl = new URL(requestOrigin); + const localUrl = localAddress && new URL(localAddress); + const networkUrl = networkAddress && new URL(networkAddress); - const networkUrl = networkAddress && new URL(networkAddress); - if (networkUrl && networkUrl.origin === requestUrl.origin) { - return true; - } + if (localUrl && localUrl.origin === requestUrl.origin) { + return true; + } + if (networkUrl && networkUrl.origin === requestUrl.origin) { + return true; + } - return ( - requestOrigin === `${localUrl.protocol}//localhost:${localUrl.port}` || - requestOrigin === `${localUrl.protocol}//127.0.0.1:${localUrl.port}` - ); + return allowedHosts.includes(requestUrl.hostname); + } catch { + return false; + } }; /** diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index aa3276064523..12265e050373 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -214,6 +214,7 @@ export interface BuilderOptions { serverChannelUrl?: string; localAddress?: string; networkAddress?: string; + allowedHosts?: string[]; } export interface StorybookConfigOptions { From 46702456ae38aeb969a7f7805f725c6941b10343 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Wed, 25 Feb 2026 09:29:18 +0500 Subject: [PATCH 17/56] fix: sort imports alphabetically in Object.tsx --- code/addons/docs/src/blocks/controls/Object.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index bf67b74a0b3b..2718c545f091 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { type Theme, styled, useTheme, srOnlyStyles } from 'storybook/theming'; +import { type Theme, srOnlyStyles, styled, useTheme } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; From 63c7cf947d039c5542f0003226d50d8f0b5b901a Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 25 Feb 2026 10:19:38 +0100 Subject: [PATCH 18/56] Object: Fix up layout issues --- code/addons/docs/src/blocks/controls/Object.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index 2718c545f091..4b6ddbb2fddf 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -20,17 +20,15 @@ const Wrapper = styled.div(({ theme }) => ({ position: 'relative', display: 'flex', isolation: 'isolate', + gap: 8, // Enable container queries for child responsive styles '@supports (container-type: inline-size)': { containerType: 'inline-size', }, - '@media (max-width: 400px)': { - flexDirection: 'column' as const, - }, - '.rejt-tree': { + flex: 1, marginLeft: '1rem', fontSize: '13px', listStyleType: 'none', @@ -136,14 +134,16 @@ const RawButton = styled(ToggleButton)({ // Container query: respond to component width (WCAG 2.1 Reflow) '@container (max-width: 400px)': { position: 'static', - alignSelf: 'flex-end', + alignSelf: 'flex-start', + order: 2, '& > span': srOnlyStyles, }, // Fallback for browsers without container query support '@media (max-width: 400px)': { position: 'static', - alignSelf: 'flex-end', + alignSelf: 'flex-start', + order: 2, '& > span': srOnlyStyles, }, }); From 57d1759398bfdfdeacecf01842f3a63cb3a4629d Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 25 Feb 2026 09:54:34 +0100 Subject: [PATCH 19/56] Implement allowedHosts configuration for WebSocket connections, update validation logic to default to allowing all hosts when host is set to '0.0.0.0' and inherit Storybook's allowedHosts configuration in Vite config. --- code/.storybook/main.ts | 4 +- code/builders/builder-vite/src/vite-server.ts | 14 ++-- code/core/src/core-server/build-dev.ts | 44 +++++++++--- .../__tests__/validate-websocket.test.ts | 70 ++++++++++++++----- .../core-server/utils/get-server-channel.ts | 14 ++-- .../core-server/utils/validate-websocket.ts | 38 ++++++---- code/core/src/types/modules/core-common.ts | 21 +++++- 7 files changed, 148 insertions(+), 57 deletions(-) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 05b99156056f..c345d14643a8 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -138,7 +138,8 @@ const config = defineMain({ }, }, core: { - disableTelemetry: true, + // disableTelemetry: true, + allowedHosts: ['storybook.ngrok-free.dev'], }, features: { developmentModeForBuild: true, @@ -172,6 +173,7 @@ const config = defineMain({ target: BROWSER_TARGETS, }, server: { + allowedHosts: ['nonstatutory-hydrodynamically-twila.ngrok-free.dev'], watch: { // Something odd happens with tsconfig and nx which causes Storybook to keep reloading, so we ignore them ignored: ['**/.nx/cache/**', '**/tsconfig.json'], diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index 9c694e94e410..896eebf9aa2e 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -16,6 +16,8 @@ export async function createViteServer(options: Options, devServer: Server) { const optimizeDeps = await getOptimizeDeps(commonCfg); + const { allowedHosts } = await presets.apply('core', {}); + const config: InlineConfig & { server: ServerOptions } = { ...commonCfg, // Set up dev server @@ -24,6 +26,7 @@ export async function createViteServer(options: Options, devServer: Server) { include: [...(commonCfg.optimizeDeps?.include || []), ...optimizeDeps.include], }, server: { + allowedHosts, middlewareMode: true, hmr: { port: options.port, @@ -36,18 +39,9 @@ export async function createViteServer(options: Options, devServer: Server) { appType: 'custom' as const, }; - // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. - // but without server.allowedHosts set, requests from outside the container will be rejected. + // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments if (options.host === '0.0.0.0' && !config.server.allowedHosts) { config.server.allowedHosts = true; - logger.warn(dedent`'host' is set to '0.0.0.0' but 'allowedHosts' is not defined. - Defaulting 'allowedHosts' to true, which permits all hostnames. - To restrict allowed hostnames, add the following to your 'viteFinal' config: - Example: { server: { allowedHosts: ['mydomain.com'] } } - See: - - https://vite.dev/config/server-options.html#server-allowedhosts - - https://storybook.js.org/docs/api/main-config/main-config-vite-final - `); } const finalConfig = await presets.apply('viteFinal', config, options); diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index e655b9cd724d..762a12aa97c1 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -23,6 +23,7 @@ import { join, relative, resolve } from 'pathe'; import invariant from 'tiny-invariant'; import { dedent } from 'ts-dedent'; +import Channel from '../channels'; import { detectPnp } from '../cli/detect'; import { resolvePackageDir } from '../shared/utils/module'; import { storybookDevServer } from './dev-server'; @@ -92,7 +93,7 @@ export async function buildDevStandalone( } invariant(port, 'expected options to have a port'); - const { address, networkAddress } = getServerAddresses( + const { address: localAddress, networkAddress } = getServerAddresses( port, options.host, options.https ? 'https' : 'http', @@ -106,7 +107,7 @@ export async function buildDevStandalone( options.cacheKey = cacheKey; options.outputDir = outputDir; options.serverChannelUrl = getServerChannelUrl(port, options); - options.localAddress = address; + options.localAddress = localAddress; options.networkAddress = networkAddress; // TODO: Remove in SB11 @@ -121,7 +122,7 @@ export async function buildDevStandalone( } const config = await loadMainConfig(options); - const { framework } = config; + const { core, framework } = config; const corePresets = []; let frameworkName = typeof framework === 'string' ? framework : framework?.name; @@ -156,7 +157,6 @@ export async function buildDevStandalone( } catch (e) {} const server = await getServer(options); - const channel = getServerChannel(server, options, getWsToken()); // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account @@ -168,10 +168,30 @@ export async function buildDevStandalone( ], ...options, isCritical: true, - channel, + channel: new Channel({ + transports: [ + { + setHandler: () => () => console.error('CHANNEL IS NOT READY YET'), + send: () => () => console.error('CHANNEL IS NOT READY YET'), + }, + ], + }), }); - const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); + const { allowedHosts, renderer, builder, disableTelemetry } = await presets.apply('core', {}); + + // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. + // By default we allow requests from all hosts in this case, but the user should be made aware of the risk. + if ( + options.host === '0.0.0.0' && + (!allowedHosts || (allowedHosts !== true && allowedHosts.length === 0)) + ) { + logger.warn(dedent` + --host is set to 0.0.0.0 but no allowedHosts are defined. Allowing all hosts. + To restrict allowed hosts, set core.allowedHosts in your main Storybook config. + See: https://storybook.js.org/docs/api/main-config/main-config-core + `); + } if (!builder) { throw new MissingBuilderError(); @@ -212,6 +232,14 @@ export async function buildDevStandalone( const resolvedRenderer = renderer && resolveAddonName(options.configDir, renderer, options); + const channel = getServerChannel(server, { + token: getWsToken(), + host: options.host, + allowedHosts, + localAddress, + networkAddress, + }); + // Load second pass: all presets are applied in order presets = await loadAllPresets({ corePresets: [ @@ -290,12 +318,12 @@ export async function buildDevStandalone( updateInfo: versionCheck, version: storybookVersion, name, - address, + address: localAddress, networkAddress, managerTotalTime, previewTotalTime, }); } } - return { port, address, networkAddress }; + return { port, address: localAddress, networkAddress }; } diff --git a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts index a6e54ef5ca5a..22213b3e2243 100644 --- a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts +++ b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts @@ -27,13 +27,14 @@ describe('isValidOrigin', () => { expect(isValidOrigin('http://127.0.0.1:6006', options)).toBe(true); }); - it('when allowedHosts is undefined, allows any origin', () => { + // TODO: Change default to [] in SB11 + it('when allowedHosts is undefined (default true), allows any origin', () => { expect(isValidOrigin('http://malicious-site.com', options)).toBe(true); expect(isValidOrigin('https://any-origin.example.com', options)).toBe(true); }); - it('when allowedHosts is ["*"], allows any origin', () => { - const allowAllOptions = { ...options, allowedHosts: ['*'] } as any; + it('when allowedHosts is true, allows any origin', () => { + const allowAllOptions = { ...options, allowedHosts: true } as any; expect(isValidOrigin('http://malicious-site.com', allowAllOptions)).toBe(true); expect(isValidOrigin('https://any-origin.example.com', allowAllOptions)).toBe(true); }); @@ -135,52 +136,89 @@ describe('isValidOrigin', () => { expect(isValidOrigin('http://localhost:6006', optionsWithEmptyAllowed)).toBe(true); expect(isValidOrigin('https://arbitrary.com', optionsWithEmptyAllowed)).toBe(false); }); + + it('when host is 0.0.0.0 and allowedHosts is empty, permits all origins', () => { + const optionsZeroHost = { + host: '0.0.0.0', + allowedHosts: [], + } as any; + expect(isValidOrigin('http://malicious-site.com', optionsZeroHost)).toBe(true); + expect(isValidOrigin('https://any-origin.example.com', optionsZeroHost)).toBe(true); + }); + + it('when host is 0.0.0.0 but allowedHosts is not empty, does not permit arbitrary origins', () => { + const optionsZeroHostWithAllowed = { + host: '0.0.0.0', + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidOrigin('http://malicious-site.com', optionsZeroHostWithAllowed)).toBe(false); + expect(isValidOrigin('https://my-app.example.com', optionsZeroHostWithAllowed)).toBe(true); + }); + + it('when allowedHosts entry includes port, matches against full host (hostname:port)', () => { + // Use non-default ports so requestUrl.host includes the port (Node omits default 443/80) + const optionsWithHostAndPort = { + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com:8443', 'other.example.com:8080'], + } as any; + expect(isValidOrigin('https://my-app.example.com:8443', optionsWithHostAndPort)).toBe(true); + expect(isValidOrigin('http://other.example.com:8080', optionsWithHostAndPort)).toBe(true); + expect(isValidOrigin('https://my-app.example.com:8444', optionsWithHostAndPort)).toBe(false); + expect(isValidOrigin('https://my-app.example.com', optionsWithHostAndPort)).toBe(false); + }); }); describe('isValidToken', () => { const validToken = 'test-token-123'; + const options = (token: string) => ({ token }) as any; + it('returns true for matching tokens', () => { - expect(isValidToken(validToken, validToken)).toBe(true); + expect(isValidToken(validToken, options(validToken))).toBe(true); }); it('returns false for non-matching tokens', () => { - expect(isValidToken('wrong-token', validToken)).toBe(false); + expect(isValidToken('wrong-token', options(validToken))).toBe(false); }); it('returns false for null token', () => { - expect(isValidToken(null, validToken)).toBe(false); + expect(isValidToken(null, options(validToken))).toBe(false); }); it('returns false for undefined token', () => { - expect(isValidToken(undefined as any, validToken)).toBe(false); + expect(isValidToken(undefined as any, options(validToken))).toBe(false); }); it('returns false for empty token', () => { - expect(isValidToken('', validToken)).toBe(false); + expect(isValidToken('', options(validToken))).toBe(false); + }); + + it('returns false when options.token is null', () => { + expect(isValidToken(validToken, { token: null } as any)).toBe(false); }); - it('returns false for null expected token', () => { - expect(isValidToken(validToken, null as any)).toBe(false); + it('returns false when options.token is undefined', () => { + expect(isValidToken(validToken, {} as any)).toBe(false); }); it('returns false for empty expected token', () => { - expect(isValidToken(validToken, '')).toBe(false); + expect(isValidToken(validToken, options(''))).toBe(false); }); it('returns false for tokens with different lengths', () => { - expect(isValidToken('short', 'much-longer-token')).toBe(false); + expect(isValidToken('short', options('much-longer-token'))).toBe(false); }); it('handles special characters in tokens', () => { const specialToken = 'token-with-special-chars-!@#$%^&*()'; - expect(isValidToken(specialToken, specialToken)).toBe(true); - expect(isValidToken(specialToken, 'different-token')).toBe(false); + expect(isValidToken(specialToken, options(specialToken))).toBe(true); + expect(isValidToken(specialToken, options('different-token'))).toBe(false); }); it('handles unicode characters in tokens', () => { const unicodeToken = 'token-with-unicode-๐Ÿš€-ๆต‹่ฏ•'; - expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); - expect(isValidToken(unicodeToken, 'different-token')).toBe(false); + expect(isValidToken(unicodeToken, options(unicodeToken))).toBe(true); + expect(isValidToken(unicodeToken, options('different-token'))).toBe(false); }); }); diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 62700410176d..911ac838c875 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -2,13 +2,12 @@ import type { IncomingMessage } from 'node:http'; import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; -import type { BuilderOptions } from 'storybook/internal/types'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; -import { isValidOrigin, isValidToken } from './validate-websocket'; +import { type ValidateWebSocketOptions, isValidOrigin, isValidToken } from './validate-websocket'; type Server = NonNullable[0]>['server']>; @@ -23,10 +22,7 @@ export class ServerChannelTransport { private handler?: ChannelHandler; - private token: string; - - constructor(server: Server, options: BuilderOptions, token: string) { - this.token = token; + constructor(server: Server, options: ValidateWebSocketOptions) { this.socket = new WebSocketServer({ noServer: true }); server.on('upgrade', (request: IncomingMessage, socket, head) => { @@ -41,7 +37,7 @@ export class ServerChannelTransport { } const requestToken = url.searchParams.get('token'); - if (!isValidToken(requestToken, this.token)) { + if (!isValidToken(requestToken, options)) { throw new Error('Invalid websocket token'); } @@ -94,8 +90,8 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server, options: BuilderOptions, token: string) { - const transports = [new ServerChannelTransport(server, options, token)]; +export function getServerChannel(server: Server, options: ValidateWebSocketOptions) { + const transports = [new ServerChannelTransport(server, options)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/validate-websocket.ts b/code/core/src/core-server/utils/validate-websocket.ts index cc3196eac695..2276e6f1afad 100644 --- a/code/core/src/core-server/utils/validate-websocket.ts +++ b/code/core/src/core-server/utils/validate-websocket.ts @@ -1,9 +1,15 @@ import { timingSafeEqual } from 'node:crypto'; -import type { BuilderOptions } from 'storybook/internal/types'; +export type ValidateWebSocketOptions = { + token: string; + host?: string; + allowedHosts?: string[] | true; + localAddress?: string; + networkAddress?: string; +}; // TODO: Change to `[]` in SB11 to change from opt-in to opt-out -const DEFAULT_ALLOWED_HOSTS: string[] = ['*']; +const DEFAULT_ALLOWED_HOSTS: string[] | true = true; /** * Validates a request origin against known local/network addresses and allowed hosts. @@ -14,9 +20,10 @@ const DEFAULT_ALLOWED_HOSTS: string[] = ['*']; */ export const isValidOrigin = ( requestOrigin: string | undefined, - { allowedHosts = DEFAULT_ALLOWED_HOSTS, localAddress, networkAddress }: BuilderOptions + options: ValidateWebSocketOptions ): boolean => { - if (allowedHosts.includes('*')) { + const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; + if (allowedHosts === true) { return true; } if (!requestOrigin) { @@ -25,8 +32,8 @@ export const isValidOrigin = ( try { const requestUrl = new URL(requestOrigin); - const localUrl = localAddress && new URL(localAddress); - const networkUrl = networkAddress && new URL(networkAddress); + const localUrl = options.localAddress && new URL(options.localAddress); + const networkUrl = options.networkAddress && new URL(options.networkAddress); if (localUrl && localUrl.origin === requestUrl.origin) { return true; @@ -34,8 +41,12 @@ export const isValidOrigin = ( if (networkUrl && networkUrl.origin === requestUrl.origin) { return true; } - - return allowedHosts.includes(requestUrl.hostname); + if (options.host === '0.0.0.0' && allowedHosts.length === 0) { + return true; + } + return allowedHosts.some((host) => + host.includes(':') ? host === requestUrl.host : host === requestUrl.hostname + ); } catch { return false; } @@ -46,13 +57,16 @@ export const isValidOrigin = ( * * @returns `true` if tokens match, `false` otherwise */ -export function isValidToken(token: string | null, expectedToken: string): boolean { - if (!token || !expectedToken) { +export function isValidToken( + requestToken: string | null, + options: ValidateWebSocketOptions +): boolean { + if (!requestToken || !options.token) { return false; } - const a = new Uint8Array(Buffer.from(token, 'utf8')); - const b = new Uint8Array(Buffer.from(expectedToken, 'utf8')); + const a = new Uint8Array(Buffer.from(requestToken, 'utf8')); + const b = new Uint8Array(Buffer.from(options.token, 'utf8')); try { return a.length === b.length && timingSafeEqual(a, b); } catch { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 12265e050373..e672c219a614 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -47,6 +47,11 @@ export interface CoreConfig { * @see https://storybook.js.org/telemetry */ enableCrashReports?: boolean; + /** + * Enable hostname validation, currently only for WebSocket connections. Set to `[]` to disallow + * all hosts except known local/network address, or `true` to allow all hosts. + */ + allowedHosts?: string[] | true; /** * Enable CORS headings to run document in a "secure context" see: * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements @@ -108,6 +113,11 @@ export interface Presets { config?: StorybookConfigRaw['staticDirs'], args?: any ): Promise; + apply( + extension: 'server', + config?: StorybookConfigRaw['server'], + args?: any + ): Promise; /** The second and third parameter are not needed. And make type inference easier. */ apply(extension: T): Promise; @@ -214,7 +224,6 @@ export interface BuilderOptions { serverChannelUrl?: string; localAddress?: string; networkAddress?: string; - allowedHosts?: string[]; } export interface StorybookConfigOptions { @@ -335,6 +344,14 @@ export interface TestBuildFlags { esbuildMinify?: boolean; } +export interface ServerConfig { + /** + * Enable hostname validation for WebSocket connections. Set to `[]` to disallow all hosts except + * known local/network address, or `['*']` to allow all hosts. + */ + allowedHosts?: string[]; +} + export interface TestBuildConfig { test?: TestBuildFlags; } @@ -519,6 +536,8 @@ export interface StorybookConfigRaw { experimentalCodeExamples?: boolean; }; + server?: ServerConfig; + build?: TestBuildConfig; stories: StoriesEntry[]; From 8d5be8404c9a0dc253f602e70caa6fe406f298d9 Mon Sep 17 00:00:00 2001 From: Vanessa Yuen <6842965+vanessayuenn@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:09:31 +0100 Subject: [PATCH 20/56] Apply suggestions from code review Co-authored-by: Kyle Gach --- docs/releases/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/index.mdx b/docs/releases/index.mdx index 06a42122c19a..8bcfc5e5c176 100644 --- a/docs/releases/index.mdx +++ b/docs/releases/index.mdx @@ -27,7 +27,7 @@ npm create storybook@next We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly based on severity: - Latest major: Receives all security fixes -- Previous two majors: Receive security patches for **High or Critical CVSS vulnerabilities only** +- Previous two majors: Receive security patches for **High or Critical [CVSS vulnerabilities](https://en.wikipedia.org/wiki/Common_Vulnerability_Scoring_System) only** - Older versions: No longer recieves any patches For example, if the latest version is `10.2.1`: From 9e48ac103faa695842d691447b508438f69a40e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:10:16 +0000 Subject: [PATCH 21/56] Initial plan From a20381def5dd28c0079d7577593e22e7793c8b54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:16:40 +0000 Subject: [PATCH 22/56] docs: add TableOfContents API reference and update available blocks list Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com> --- .../doc-blocks/doc-block-tableofcontents.mdx | 90 +++++++++++++++++++ docs/api/doc-blocks/doc-block-title.mdx | 2 +- docs/api/doc-blocks/doc-block-typeset.mdx | 2 +- docs/api/doc-blocks/doc-block-unstyled.mdx | 2 +- docs/api/doc-blocks/doc-block-useof.mdx | 2 +- docs/writing-docs/doc-blocks.mdx | 10 +++ 6 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 docs/api/doc-blocks/doc-block-tableofcontents.mdx diff --git a/docs/api/doc-blocks/doc-block-tableofcontents.mdx b/docs/api/doc-blocks/doc-block-tableofcontents.mdx new file mode 100644 index 000000000000..f8743da6fa80 --- /dev/null +++ b/docs/api/doc-blocks/doc-block-tableofcontents.mdx @@ -0,0 +1,90 @@ +--- +title: 'TableOfContents' +sidebar: + order: 14 + title: TableOfContents +--- + +The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to quickly navigate between sections. It appears as a fixed sidebar on the right side of the documentation page and is hidden on smaller screens (below 768px). + +The table of contents is enabled and configured via the `docs.toc` [parameter](../../writing-stories/parameters.mdx) rather than being added directly to MDX files. When enabled, it is automatically rendered alongside the page content by [Storybook's docs container](../doc-blocks/doc-block-meta.mdx). + + + + For a step-by-step guide on enabling and customizing the table of contents, see the [Generate a table of contents](../../writing-docs/autodocs.mdx#generate-a-table-of-contents) section in the Autodocs documentation. + + + +## Enabling the table of contents + +Enable the table of contents globally in your Storybook preview configuration: + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +You can also enable or disable it for specific components in their stories file: + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +## `toc` parameter options + +The `docs.toc` parameter accepts either `true` (to enable with defaults) or an object with the following properties: + +### `contentsSelector` + +Type: `string` + +Default: `'.sbdocs-content'` + +CSS selector for the container to search for headings. Use this if you have a custom docs page layout. + +### `disable` + +Type: `boolean` + +Default: `false` + +When `true`, hides the table of contents for the documentation page. A hidden (empty) container is still rendered to preserve the page layout. + +### `headingSelector` + +Type: `string` + +Default: `'h3'` + +CSS selector that defines which heading levels to include in the table of contents. For example, use `'h1, h2, h3'` to include the top three heading levels. + +### `ignoreSelector` + +Type: `string` + +Default: `'.docs-story *, .skip-toc'` + +CSS selector for headings to exclude from the table of contents. By default, headings inside story blocks are excluded. To also exclude a specific heading, add the `skip-toc` class to it. + +### `title` + +Type: `string | null | ReactElement` + +Default: `'Table of contents'` (visually hidden) + +Text or element to display as the title above the table of contents. Set to `null` to render no title. When a string is provided it is rendered as a visually hidden `

` by default; pass a non-empty string to make it visible. + +### `unsafeTocbotOptions` + +Type: `object` + +Provides additional configuration options passed directly to the underlying [`Tocbot`](https://tscanlin.github.io/tocbot/) library. These options are not guaranteed to remain available in future versions of Storybook. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} diff --git a/docs/api/doc-blocks/doc-block-title.mdx b/docs/api/doc-blocks/doc-block-title.mdx index e5ffc02a82f0..1c40d97e7499 100644 --- a/docs/api/doc-blocks/doc-block-title.mdx +++ b/docs/api/doc-blocks/doc-block-title.mdx @@ -1,7 +1,7 @@ --- title: 'Title' sidebar: - order: 14 + order: 15 title: Title --- diff --git a/docs/api/doc-blocks/doc-block-typeset.mdx b/docs/api/doc-blocks/doc-block-typeset.mdx index 7e507541f7f8..f7c49b5dabe6 100644 --- a/docs/api/doc-blocks/doc-block-typeset.mdx +++ b/docs/api/doc-blocks/doc-block-typeset.mdx @@ -1,7 +1,7 @@ --- title: 'Typeset' sidebar: - order: 15 + order: 16 title: Typeset --- diff --git a/docs/api/doc-blocks/doc-block-unstyled.mdx b/docs/api/doc-blocks/doc-block-unstyled.mdx index 4ef7db0320a2..addc5f677448 100644 --- a/docs/api/doc-blocks/doc-block-unstyled.mdx +++ b/docs/api/doc-blocks/doc-block-unstyled.mdx @@ -1,7 +1,7 @@ --- title: 'Unstyled' sidebar: - order: 16 + order: 17 title: Unstyled --- diff --git a/docs/api/doc-blocks/doc-block-useof.mdx b/docs/api/doc-blocks/doc-block-useof.mdx index beb560eb48d9..a47517330bad 100644 --- a/docs/api/doc-blocks/doc-block-useof.mdx +++ b/docs/api/doc-blocks/doc-block-useof.mdx @@ -1,7 +1,7 @@ --- title: 'useOf' sidebar: - order: 17 + order: 18 title: useOf --- diff --git a/docs/writing-docs/doc-blocks.mdx b/docs/writing-docs/doc-blocks.mdx index cd360b99ce0d..ea52c2ada97d 100644 --- a/docs/writing-docs/doc-blocks.mdx +++ b/docs/writing-docs/doc-blocks.mdx @@ -240,6 +240,16 @@ The `Subtitle` block can serve as a secondary heading for your docs entry. ![Screenshot of Subtitle block](../_assets/api/doc-block-title-subtitle-description.png) +### [TableOfContents](../api/doc-blocks/doc-block-tableofcontents.mdx) + + + + Accepts parameters in the namespace `parameters.docs.toc`. + + + +The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to quickly navigate between sections. It appears as a fixed sidebar on the right side of the page. + ### [Title](../api/doc-blocks/doc-block-title.mdx) The `Title` block serves as the primary heading for your docs entry. It is typically used to provide the component or page name. From ba31175534cfa4cfa917bd440beed50f78bb2890 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 25 Feb 2026 17:28:27 +0100 Subject: [PATCH 23/56] Restore config --- code/.storybook/main.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index c345d14643a8..9fc833e56f26 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -138,8 +138,7 @@ const config = defineMain({ }, }, core: { - // disableTelemetry: true, - allowedHosts: ['storybook.ngrok-free.dev'], + disableTelemetry: true, }, features: { developmentModeForBuild: true, From 547521bbfc16b1e4ff05090cc8f4dfd094183b8b Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Thu, 26 Feb 2026 23:35:59 +0900 Subject: [PATCH 24/56] feat: Introduce a `hidden` prop to the `Button` component and utilize it for the zoom reset button. --- .../components/components/Button/Button.tsx | 7 ++++++- .../manager/components/preview/tools/zoom.tsx | 19 +++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index dbf24c1622df..a88d58c6ceb7 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -60,6 +60,7 @@ export const Button = forwardRef( variant = 'outline', padding = 'medium', disabled = false, + hidden = false, readOnly = false, active, onClick, @@ -138,7 +139,8 @@ export const Button = forwardRef( variant={variant} size={size} padding={padding} - disabled={disabled || readOnly} + disabled={disabled || hidden || readOnly} + hidden={hidden} readOnly={readOnly} active={active} animating={isAnimating} @@ -166,6 +168,7 @@ const StyledButton = styled('button', { variant?: 'outline' | 'solid' | 'ghost'; active?: boolean; disabled?: boolean; + hidden?: boolean; readOnly?: boolean; animating?: boolean; animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; @@ -175,6 +178,7 @@ const StyledButton = styled('button', { variant, size, disabled, + hidden, readOnly, active, animating, @@ -217,6 +221,7 @@ const StyledButton = styled('button', { whiteSpace: 'nowrap', userSelect: 'none', opacity: disabled && !readOnly ? 0.5 : 1, + visibility: hidden ? 'hidden' : 'visible', margin: 0, fontSize: `${theme.typography.size.s1}px`, fontWeight: theme.typography.weight.bold, diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 2ca48a5c5efb..9dd462f4e6e9 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -81,16 +81,15 @@ export const Zoom = memo<{ } after={ - value !== INITIAL_ZOOM_LEVEL && ( - zoomTo(INITIAL_ZOOM_LEVEL)} - ariaLabel="Reset zoom" - > - - - ) + } value={`${Math.round(value * 100)}%`} minValue={1} From bd039362f471df37860808ccc3ad8ceb612b94ae Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 26 Feb 2026 17:25:50 +0100 Subject: [PATCH 25/56] Add host-validation-middleware to enhance WebSocket security and validation logic - Updated package.json to include host-validation-middleware@^0.1.2. - Integrated host-validation-middleware in dev-server for improved host validation. - Refactored WebSocket validation logic to utilize host-validation-middleware for origin and token checks. - Adjusted tests to reflect changes in validation logic and ensure proper functionality. --- code/core/package.json | 1 + code/core/src/core-server/dev-server.ts | 10 ++ .../getHostValidationMiddleware.test.ts | 97 +++++++++++++++++++ .../utils/__tests__/server-channel.test.ts | 31 +++--- .../__tests__/validate-websocket.test.ts | 66 ++++++------- .../core-server/utils/get-server-channel.ts | 13 ++- .../utils/getHostValidationMiddleware.ts | 34 +++++++ .../core-server/utils/validate-websocket.ts | 28 ++---- yarn.lock | 8 ++ 9 files changed, 214 insertions(+), 74 deletions(-) create mode 100644 code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts create mode 100644 code/core/src/core-server/utils/getHostValidationMiddleware.ts diff --git a/code/core/package.json b/code/core/package.json index 63983e73c279..6ded84d0e6b8 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -320,6 +320,7 @@ "get-npm-tarball-url": "^2.1.0", "glob": "^10.5.0", "globby": "^14.1.0", + "host-validation-middleware": "^0.1.2", "jiti": "^2.6.1", "js-yaml": "^4.1.0", "jsdoc-type-pratt-parser": "^4.0.0", diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 2def18dd7b3b..bb4fd8cbf8d9 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -4,6 +4,7 @@ import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; +import { hostValidationMiddleware } from 'host-validation-middleware'; import polka from 'polka'; import { telemetry } from '../telemetry'; @@ -12,6 +13,7 @@ import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; +import { getHostValidationMiddleware } from './utils/getHostValidationMiddleware'; import { registerIndexJsonRoute } from './utils/index-json'; import { registerManifests } from './utils/manifests/manifests'; import { useStorybookMetadata } from './utils/metadata'; @@ -48,6 +50,14 @@ export async function storybookDevServer( options.extendServer(server); } + app.use( + getHostValidationMiddleware({ + host: options.host, + allowedHosts: core?.allowedHosts, + localAddress: options.localAddress, + networkAddress: options.networkAddress, + }) + ); app.use(getAccessControlMiddleware(core?.crossOriginIsolated ?? false)); app.use(getCachingMiddleware()); diff --git a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts new file mode 100644 index 000000000000..049d84a73182 --- /dev/null +++ b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getHostValidationMiddleware } from '../getHostValidationMiddleware'; + +function createMockRequest(headers: Record) { + return { headers } as any; +} + +function createMockResponse() { + const res: any = { + writeHead: vi.fn(), + end: vi.fn(), + }; + return res; +} + +describe('getHostValidationMiddleware', () => { + it('calls next() when allowedHosts is true (allow all)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: true, + }); + const req = createMockRequest({ origin: 'http://malicious-site.com' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('returns 403 when Host is invalid (strict allowedHosts)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: ['localhost'], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'evil.com:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' }); + expect(res.end).toHaveBeenCalledWith('Invalid origin'); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when Host is valid (matches localAddress)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: ['localhost'], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'localhost:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('returns 403 when Host header is absent (allowedHosts not true)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: ['localhost'], + localAddress: 'http://localhost:6006', + }); + const req = createMockRequest({}); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when Host matches allowedHosts hostname (custom domain)', () => { + const middleware = getHostValidationMiddleware({ + allowedHosts: ['my-app.example.com'], + }); + const req = createMockRequest({ host: 'my-app.example.com:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); +}); diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index ab803b2c869a..4da1e7004e11 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -8,28 +8,29 @@ import { stringify } from 'telejson'; import { ServerChannelTransport, getServerChannel } from '../get-server-channel'; +const mockToken = 'test-token-123'; + const options = { localAddress: 'http://localhost:6006', networkAddress: 'http://192.168.1.100:6006', + token: mockToken, } as any; describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; - const result = getServerChannel(server, options, 'test-token-123'); + const result = getServerChannel(server, options); expect(result).toBeInstanceOf(Channel); }); it('should attach to the http server', () => { const server = { on: vi.fn() } as any as Server; - getServerChannel(server, options, 'test-token-123'); + getServerChannel(server, options); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); describe('ServerChannelTransport', () => { - const mockToken = 'test-token-123'; - beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -41,7 +42,7 @@ describe('ServerChannelTransport', () => { it('parses simple JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -55,7 +56,7 @@ describe('ServerChannelTransport', () => { it('parses object JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -69,7 +70,7 @@ describe('ServerChannelTransport', () => { it('supports telejson cyclical data', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -94,7 +95,7 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Simulate upgrade request without token const request = { @@ -120,7 +121,7 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - new ServerChannelTransport(server, options, mockToken); + new ServerChannelTransport(server, options); // Simulate upgrade request with wrong token const request = { @@ -146,7 +147,7 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) @@ -174,7 +175,7 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Simulate upgrade request with invalid origin const request = { @@ -200,7 +201,7 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Simulate upgrade request without origin header const request = { @@ -225,7 +226,7 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) @@ -255,7 +256,7 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) @@ -285,7 +286,7 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, options, mockToken); + const transport = new ServerChannelTransport(server, options); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) diff --git a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts index 22213b3e2243..976936fc4490 100644 --- a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts +++ b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts @@ -43,12 +43,12 @@ describe('isValidOrigin', () => { expect(isValidOrigin('http://malicious-site.com', strictOptions)).toBe(false); }); - it('returns false for different port', () => { - expect(isValidOrigin('http://localhost:8080', strictOptions)).toBe(false); + it('returns true for localhost with different port (host-validation-middleware allows localhost)', () => { + expect(isValidOrigin('http://localhost:8080', strictOptions)).toBe(true); }); - it('returns false for different protocol', () => { - expect(isValidOrigin('https://localhost:6006', strictOptions)).toBe(false); + it('returns true for localhost with different protocol (host-validation-middleware allows localhost)', () => { + expect(isValidOrigin('https://localhost:6006', strictOptions)).toBe(true); }); it('returns false for undefined origin when not allow-all', () => { @@ -59,12 +59,12 @@ describe('isValidOrigin', () => { expect(isValidOrigin(null as any, strictOptions)).toBe(false); }); - it('returns false when localAddress is missing and allowedHosts is empty', () => { + it('returns true when localAddress is missing but hostname is localhost (host-validation-middleware allows localhost)', () => { const optionsWithoutLocal = { networkAddress: 'http://192.168.1.100:6006', allowedHosts: [], } as any; - expect(isValidOrigin('http://localhost:6006', optionsWithoutLocal)).toBe(false); + expect(isValidOrigin('http://localhost:6006', optionsWithoutLocal)).toBe(true); }); it('handles https protocol correctly', () => { @@ -75,7 +75,7 @@ describe('isValidOrigin', () => { expect(isValidOrigin('https://localhost:6006', httpsOptions)).toBe(true); expect(isValidOrigin('https://192.168.1.100:6006', httpsOptions)).toBe(true); const strictHttpsOptions = { ...httpsOptions, allowedHosts: [] } as any; - expect(isValidOrigin('http://localhost:6006', strictHttpsOptions)).toBe(false); + expect(isValidOrigin('http://localhost:6006', strictHttpsOptions)).toBe(true); }); it('handles network address without port', () => { @@ -84,11 +84,11 @@ describe('isValidOrigin', () => { } as any; expect(isValidOrigin('http://localhost:6006', optionsWithoutNetwork)).toBe(true); const strictNoNetwork = { ...optionsWithoutNetwork, allowedHosts: [] } as any; - expect(isValidOrigin('http://192.168.1.100:6006', strictNoNetwork)).toBe(false); + expect(isValidOrigin('http://192.168.1.100:6006', strictNoNetwork)).toBe(true); }); - it('handles different network address correctly', () => { - expect(isValidOrigin('http://10.0.0.1:6006', strictOptions)).toBe(false); + it('returns true for different network IP (host-validation-middleware allows any IPv4)', () => { + expect(isValidOrigin('http://10.0.0.1:6006', strictOptions)).toBe(true); }); it('handles origin with path', () => { @@ -156,69 +156,65 @@ describe('isValidOrigin', () => { expect(isValidOrigin('https://my-app.example.com', optionsZeroHostWithAllowed)).toBe(true); }); - it('when allowedHosts entry includes port, matches against full host (hostname:port)', () => { - // Use non-default ports so requestUrl.host includes the port (Node omits default 443/80) - const optionsWithHostAndPort = { + it('when allowedHosts has hostnames, matches by hostname (host-validation-middleware ignores port in allowedHosts)', () => { + const optionsWithAllowed = { localAddress: 'http://localhost:6006', - allowedHosts: ['my-app.example.com:8443', 'other.example.com:8080'], + allowedHosts: ['my-app.example.com', 'other.example.com'], } as any; - expect(isValidOrigin('https://my-app.example.com:8443', optionsWithHostAndPort)).toBe(true); - expect(isValidOrigin('http://other.example.com:8080', optionsWithHostAndPort)).toBe(true); - expect(isValidOrigin('https://my-app.example.com:8444', optionsWithHostAndPort)).toBe(false); - expect(isValidOrigin('https://my-app.example.com', optionsWithHostAndPort)).toBe(false); + expect(isValidOrigin('https://my-app.example.com:8443', optionsWithAllowed)).toBe(true); + expect(isValidOrigin('http://other.example.com:8080', optionsWithAllowed)).toBe(true); + expect(isValidOrigin('https://other-site.com:8080', optionsWithAllowed)).toBe(false); }); }); describe('isValidToken', () => { const validToken = 'test-token-123'; - const options = (token: string) => ({ token }) as any; - it('returns true for matching tokens', () => { - expect(isValidToken(validToken, options(validToken))).toBe(true); + expect(isValidToken(validToken, validToken)).toBe(true); }); it('returns false for non-matching tokens', () => { - expect(isValidToken('wrong-token', options(validToken))).toBe(false); + expect(isValidToken('wrong-token', validToken)).toBe(false); }); it('returns false for null token', () => { - expect(isValidToken(null, options(validToken))).toBe(false); + expect(isValidToken(null, validToken)).toBe(false); }); it('returns false for undefined token', () => { - expect(isValidToken(undefined as any, options(validToken))).toBe(false); + expect(isValidToken(undefined as any, validToken)).toBe(false); }); it('returns false for empty token', () => { - expect(isValidToken('', options(validToken))).toBe(false); + expect(isValidToken('', validToken)).toBe(false); }); - it('returns false when options.token is null', () => { - expect(isValidToken(validToken, { token: null } as any)).toBe(false); + it('returns false when expected token is null', () => { + expect(isValidToken(validToken, null as any)).toBe(false); }); - it('returns false when options.token is undefined', () => { - expect(isValidToken(validToken, {} as any)).toBe(false); + it('returns false when expected token is undefined', () => { + expect(isValidToken(validToken, undefined as any)).toBe(false); }); it('returns false for empty expected token', () => { - expect(isValidToken(validToken, options(''))).toBe(false); + expect(isValidToken(validToken, '')).toBe(false); }); it('returns false for tokens with different lengths', () => { - expect(isValidToken('short', options('much-longer-token'))).toBe(false); + expect(isValidToken('short', 'much-longer-token')).toBe(false); }); it('handles special characters in tokens', () => { const specialToken = 'token-with-special-chars-!@#$%^&*()'; - expect(isValidToken(specialToken, options(specialToken))).toBe(true); - expect(isValidToken(specialToken, options('different-token'))).toBe(false); + expect(isValidToken(specialToken, specialToken)).toBe(true); + expect(isValidToken(specialToken, 'different-token')).toBe(false); }); it('handles unicode characters in tokens', () => { const unicodeToken = 'token-with-unicode-๐Ÿš€-ๆต‹่ฏ•'; - expect(isValidToken(unicodeToken, options(unicodeToken))).toBe(true); - expect(isValidToken(unicodeToken, options('different-token'))).toBe(false); + expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); + expect(isValidToken(unicodeToken, 'different-token')).toBe(false); }); }); diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 911ac838c875..a8a29af1b9b4 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -7,10 +7,15 @@ import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; -import { type ValidateWebSocketOptions, isValidOrigin, isValidToken } from './validate-websocket'; +import { type HostValidationOptions } from './getHostValidationMiddleware'; +import { isValidOrigin, isValidToken } from './validate-websocket'; type Server = NonNullable[0]>['server']>; +type ServerChannelTransportOptions = HostValidationOptions & { + token: string; +}; + /** * This class represents a channel transport that allows for a one-to-many relationship between the * server and clients. Unlike other channels such as the postmessage and websocket channel @@ -22,7 +27,7 @@ export class ServerChannelTransport { private handler?: ChannelHandler; - constructor(server: Server, options: ValidateWebSocketOptions) { + constructor(server: Server, options: ServerChannelTransportOptions) { this.socket = new WebSocketServer({ noServer: true }); server.on('upgrade', (request: IncomingMessage, socket, head) => { @@ -37,7 +42,7 @@ export class ServerChannelTransport { } const requestToken = url.searchParams.get('token'); - if (!isValidToken(requestToken, options)) { + if (!isValidToken(requestToken, options.token)) { throw new Error('Invalid websocket token'); } @@ -90,7 +95,7 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server, options: ValidateWebSocketOptions) { +export function getServerChannel(server: Server, options: ServerChannelTransportOptions) { const transports = [new ServerChannelTransport(server, options)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/getHostValidationMiddleware.ts b/code/core/src/core-server/utils/getHostValidationMiddleware.ts new file mode 100644 index 000000000000..2e674c75d8c3 --- /dev/null +++ b/code/core/src/core-server/utils/getHostValidationMiddleware.ts @@ -0,0 +1,34 @@ +import type { IncomingMessage } from 'node:http'; + +import { isHostAllowed } from 'host-validation-middleware'; + +import type { Middleware } from '../../types'; + +// TODO: Change to `[]` in SB11 to change from opt-in to opt-out +export const DEFAULT_ALLOWED_HOSTS: string[] | true = true; + +export type HostValidationOptions = { + host?: string; + allowedHosts?: string[] | true; + localAddress?: string; + networkAddress?: string; +}; + +/** + * Validates the Host header against known local/network addresses and allowed hosts. Requests with + * no Host header (e.g. same-origin navigation, GET from address bar) are not allowed. + */ +export function getHostValidationMiddleware( + options: HostValidationOptions +): Middleware { + return (req, res, next) => { + const host = req.headers.host; + const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; + if (allowedHosts !== true && (!host || !isHostAllowed(host, allowedHosts))) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Invalid origin'); + return; + } + next(); + }; +} diff --git a/code/core/src/core-server/utils/validate-websocket.ts b/code/core/src/core-server/utils/validate-websocket.ts index 2276e6f1afad..16e9958f106b 100644 --- a/code/core/src/core-server/utils/validate-websocket.ts +++ b/code/core/src/core-server/utils/validate-websocket.ts @@ -1,18 +1,11 @@ import { timingSafeEqual } from 'node:crypto'; -export type ValidateWebSocketOptions = { - token: string; - host?: string; - allowedHosts?: string[] | true; - localAddress?: string; - networkAddress?: string; -}; +import { isHostAllowed } from 'host-validation-middleware'; -// TODO: Change to `[]` in SB11 to change from opt-in to opt-out -const DEFAULT_ALLOWED_HOSTS: string[] | true = true; +import { DEFAULT_ALLOWED_HOSTS, type HostValidationOptions } from './getHostValidationMiddleware'; /** - * Validates a request origin against known local/network addresses and allowed hosts. + * Validates a request Origin against known local/network addresses and allowed hosts. * * @param requestOrigin - The origin header value to validate. * @param options - The builder options. @@ -20,7 +13,7 @@ const DEFAULT_ALLOWED_HOSTS: string[] | true = true; */ export const isValidOrigin = ( requestOrigin: string | undefined, - options: ValidateWebSocketOptions + options: HostValidationOptions ): boolean => { const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; if (allowedHosts === true) { @@ -44,9 +37,7 @@ export const isValidOrigin = ( if (options.host === '0.0.0.0' && allowedHosts.length === 0) { return true; } - return allowedHosts.some((host) => - host.includes(':') ? host === requestUrl.host : host === requestUrl.hostname - ); + return isHostAllowed(requestUrl.host, allowedHosts); } catch { return false; } @@ -57,16 +48,13 @@ export const isValidOrigin = ( * * @returns `true` if tokens match, `false` otherwise */ -export function isValidToken( - requestToken: string | null, - options: ValidateWebSocketOptions -): boolean { - if (!requestToken || !options.token) { +export function isValidToken(requestToken: string | null, expectedToken: string): boolean { + if (!requestToken || !expectedToken) { return false; } const a = new Uint8Array(Buffer.from(requestToken, 'utf8')); - const b = new Uint8Array(Buffer.from(options.token, 'utf8')); + const b = new Uint8Array(Buffer.from(expectedToken, 'utf8')); try { return a.length === b.length && timingSafeEqual(a, b); } catch { diff --git a/yarn.lock b/yarn.lock index 73518103a089..e71600c0b47c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18839,6 +18839,13 @@ __metadata: languageName: node linkType: hard +"host-validation-middleware@npm:^0.1.2": + version: 0.1.2 + resolution: "host-validation-middleware@npm:0.1.2" + checksum: 10c0/42a36849ed4898b7af574b4e5ea57070d8fcd7b98f6c7c027cd301612a83a48bf812066ef83fbc2ba9e7a50bc5986215ec802f06b64e14988f2117fbeb6bc24c + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4, hosted-git-info@npm:^2.7.1": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -28463,6 +28470,7 @@ __metadata: get-npm-tarball-url: "npm:^2.1.0" glob: "npm:^10.5.0" globby: "npm:^14.1.0" + host-validation-middleware: "npm:^0.1.2" jiti: "npm:^2.6.1" js-yaml: "npm:^4.1.0" jsdoc-type-pratt-parser: "npm:^4.0.0" From 453de7925e55e09262fcadcaaf873f1d14f55be4 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 27 Feb 2026 00:24:20 +0100 Subject: [PATCH 26/56] Refactor host validation logic and introduce token validation - Removed the deprecated host-validation-middleware import from dev-server. - Updated get-server-channel to use isValidHost for origin validation. - Added isValidHost function to getHostValidationMiddleware for improved host validation. - Introduced validate-token utility for secure token validation. - Removed obsolete validate-websocket utility and its associated tests. - Updated tests for getHostValidationMiddleware to reflect new validation logic and added tests for isValidToken. --- code/core/src/core-server/dev-server.ts | 1 - .../getHostValidationMiddleware.test.ts | 195 +++++++++++++++- .../utils/__tests__/validate-token.test.ts | 55 +++++ .../__tests__/validate-websocket.test.ts | 220 ------------------ .../core-server/utils/get-server-channel.ts | 7 +- .../utils/getHostValidationMiddleware.ts | 38 ++- .../src/core-server/utils/validate-token.ts | 20 ++ .../core-server/utils/validate-websocket.ts | 63 ----- 8 files changed, 307 insertions(+), 292 deletions(-) create mode 100644 code/core/src/core-server/utils/__tests__/validate-token.test.ts delete mode 100644 code/core/src/core-server/utils/__tests__/validate-websocket.test.ts create mode 100644 code/core/src/core-server/utils/validate-token.ts delete mode 100644 code/core/src/core-server/utils/validate-websocket.ts diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index bb4fd8cbf8d9..8fa66c62499f 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -4,7 +4,6 @@ import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; -import { hostValidationMiddleware } from 'host-validation-middleware'; import polka from 'polka'; import { telemetry } from '../telemetry'; diff --git a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts index 049d84a73182..431421de3b10 100644 --- a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts +++ b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { getHostValidationMiddleware } from '../getHostValidationMiddleware'; +import { getHostValidationMiddleware, isValidHost } from '../getHostValidationMiddleware'; function createMockRequest(headers: Record) { return { headers } as any; @@ -20,7 +20,7 @@ describe('getHostValidationMiddleware', () => { host: 'localhost', allowedHosts: true, }); - const req = createMockRequest({ origin: 'http://malicious-site.com' }); + const req = createMockRequest({ host: 'malicious-site.com:6006' }); const res = createMockResponse(); const next = vi.fn(); @@ -44,7 +44,7 @@ describe('getHostValidationMiddleware', () => { middleware(req, res, next); expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' }); - expect(res.end).toHaveBeenCalledWith('Invalid origin'); + expect(res.end).toHaveBeenCalledWith('Invalid host'); expect(next).not.toHaveBeenCalled(); }); @@ -65,6 +65,40 @@ describe('getHostValidationMiddleware', () => { expect(res.writeHead).not.toHaveBeenCalled(); }); + it('calls next() when Host matches networkAddress', () => { + const middleware = getHostValidationMiddleware({ + host: '0.0.0.0', + allowedHosts: ['localhost'], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: '192.168.1.100:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('calls next() when allowedHosts is empty but Host matches localAddress', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: [], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'localhost:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + it('returns 403 when Host header is absent (allowedHosts not true)', () => { const middleware = getHostValidationMiddleware({ host: 'localhost', @@ -95,3 +129,158 @@ describe('getHostValidationMiddleware', () => { expect(res.writeHead).not.toHaveBeenCalled(); }); }); + +describe('isValidHost', () => { + const options = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + } as any; + + /** When allowedHosts is set to [], only local/network hosts are allowed (no default allow-all). */ + const strictOptions = { ...options, allowedHosts: [] } as any; + + it('returns true for exact local address match', () => { + expect(isValidHost('localhost:6006', options)).toBe(true); + }); + + it('returns true for network address match', () => { + expect(isValidHost('192.168.1.100:6006', options)).toBe(true); + }); + + it('returns true for localhost host', () => { + expect(isValidHost('localhost:6006', options)).toBe(true); + }); + + it('returns true for 127.0.0.1 host', () => { + expect(isValidHost('127.0.0.1:6006', options)).toBe(true); + }); + + // TODO: Change default to [] in SB11 + it('when allowedHosts is undefined (default true), allows any host', () => { + expect(isValidHost('malicious-site.com', options)).toBe(true); + expect(isValidHost('any-origin.example.com', options)).toBe(true); + }); + + it('when allowedHosts is true, allows any host', () => { + const allowAllOptions = { ...options, allowedHosts: true } as any; + expect(isValidHost('malicious-site.com', allowAllOptions)).toBe(true); + expect(isValidHost('any-origin.example.com', allowAllOptions)).toBe(true); + }); + + it('returns false for different host when allowedHosts is empty', () => { + expect(isValidHost('malicious-site.com', strictOptions)).toBe(false); + }); + + it('returns true for localhost with different port (host-validation-middleware allows localhost)', () => { + expect(isValidHost('localhost:8080', strictOptions)).toBe(true); + }); + + it('returns true for localhost with same host (host-validation-middleware allows localhost)', () => { + expect(isValidHost('localhost:6006', strictOptions)).toBe(true); + }); + + it('returns false for undefined host when not allow-all', () => { + expect(isValidHost(undefined, strictOptions)).toBe(false); + }); + + it('returns false for null host when not allow-all', () => { + expect(isValidHost(null as any, strictOptions)).toBe(false); + }); + + it('returns true when localAddress is missing but hostname is localhost (host-validation-middleware allows localhost)', () => { + const optionsWithoutLocal = { + networkAddress: 'http://192.168.1.100:6006', + allowedHosts: [], + } as any; + expect(isValidHost('localhost:6006', optionsWithoutLocal)).toBe(true); + }); + + it('handles https local/network addresses correctly', () => { + const httpsOptions = { + localAddress: 'https://localhost:6006', + networkAddress: 'https://192.168.1.100:6006', + } as any; + expect(isValidHost('localhost:6006', httpsOptions)).toBe(true); + expect(isValidHost('192.168.1.100:6006', httpsOptions)).toBe(true); + const strictHttpsOptions = { ...httpsOptions, allowedHosts: [] } as any; + expect(isValidHost('localhost:6006', strictHttpsOptions)).toBe(true); + }); + + it('handles network address without port', () => { + const optionsWithoutNetwork = { + localAddress: 'http://localhost:6006', + } as any; + expect(isValidHost('localhost:6006', optionsWithoutNetwork)).toBe(true); + const strictNoNetwork = { ...optionsWithoutNetwork, allowedHosts: [] } as any; + expect(isValidHost('192.168.1.100:6006', strictNoNetwork)).toBe(true); + }); + + it('returns true for different network IP (host-validation-middleware allows any IPv4)', () => { + expect(isValidHost('10.0.0.1:6006', strictOptions)).toBe(true); + }); + + it('returns true when request host is in allowedHosts', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com', 'other.example.com'], + } as any; + expect(isValidHost('my-app.example.com', optionsWithAllowed)).toBe(true); + expect(isValidHost('other.example.com', optionsWithAllowed)).toBe(true); + }); + + it('returns false when request host is not in allowedHosts and not local/network', () => { + const optionsWithAllowed = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('other-site.com', optionsWithAllowed)).toBe(false); + }); + + it('allowedHosts does not override default local/network checks', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('localhost:6006', optionsWithAllowed)).toBe(true); + expect(isValidHost('192.168.1.100:6006', optionsWithAllowed)).toBe(true); + }); + + it('empty allowedHosts does not allow arbitrary hosts', () => { + const optionsWithEmptyAllowed = { + localAddress: 'http://localhost:6006', + allowedHosts: [], + } as any; + expect(isValidHost('localhost:6006', optionsWithEmptyAllowed)).toBe(true); + expect(isValidHost('arbitrary.com', optionsWithEmptyAllowed)).toBe(false); + }); + + it('when host is 0.0.0.0 and allowedHosts is empty, permits all hosts', () => { + const optionsZeroHost = { + host: '0.0.0.0', + allowedHosts: [], + } as any; + expect(isValidHost('malicious-site.com', optionsZeroHost)).toBe(true); + expect(isValidHost('any-origin.example.com', optionsZeroHost)).toBe(true); + }); + + it('when host is 0.0.0.0 but allowedHosts is not empty, does not permit arbitrary hosts', () => { + const optionsZeroHostWithAllowed = { + host: '0.0.0.0', + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('malicious-site.com', optionsZeroHostWithAllowed)).toBe(false); + expect(isValidHost('my-app.example.com', optionsZeroHostWithAllowed)).toBe(true); + }); + + it('when allowedHosts has hostnames, matches by hostname (host-validation-middleware ignores port in allowedHosts)', () => { + const optionsWithAllowed = { + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com', 'other.example.com'], + } as any; + expect(isValidHost('my-app.example.com:8443', optionsWithAllowed)).toBe(true); + expect(isValidHost('other.example.com:8080', optionsWithAllowed)).toBe(true); + expect(isValidHost('other-site.com:8080', optionsWithAllowed)).toBe(false); + }); +}); diff --git a/code/core/src/core-server/utils/__tests__/validate-token.test.ts b/code/core/src/core-server/utils/__tests__/validate-token.test.ts new file mode 100644 index 000000000000..c377d781e48f --- /dev/null +++ b/code/core/src/core-server/utils/__tests__/validate-token.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { isValidToken } from '../validate-token'; + +describe('isValidToken', () => { + const validToken = 'test-token-123'; + + it('returns true for matching tokens', () => { + expect(isValidToken(validToken, validToken)).toBe(true); + }); + + it('returns false for non-matching tokens', () => { + expect(isValidToken('wrong-token', validToken)).toBe(false); + }); + + it('returns false for null token', () => { + expect(isValidToken(null, validToken)).toBe(false); + }); + + it('returns false for undefined token', () => { + expect(isValidToken(undefined as any, validToken)).toBe(false); + }); + + it('returns false for empty token', () => { + expect(isValidToken('', validToken)).toBe(false); + }); + + it('returns false when expected token is null', () => { + expect(isValidToken(validToken, null as any)).toBe(false); + }); + + it('returns false when expected token is undefined', () => { + expect(isValidToken(validToken, undefined as any)).toBe(false); + }); + + it('returns false for empty expected token', () => { + expect(isValidToken(validToken, '')).toBe(false); + }); + + it('returns false for tokens with different lengths', () => { + expect(isValidToken('short', 'much-longer-token')).toBe(false); + }); + + it('handles special characters in tokens', () => { + const specialToken = 'token-with-special-chars-!@#$%^&*()'; + expect(isValidToken(specialToken, specialToken)).toBe(true); + expect(isValidToken(specialToken, 'different-token')).toBe(false); + }); + + it('handles unicode characters in tokens', () => { + const unicodeToken = 'token-with-unicode-๐Ÿš€-ๆต‹่ฏ•'; + expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); + expect(isValidToken(unicodeToken, 'different-token')).toBe(false); + }); +}); diff --git a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts b/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts deleted file mode 100644 index 976936fc4490..000000000000 --- a/code/core/src/core-server/utils/__tests__/validate-websocket.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isValidOrigin, isValidToken } from '../validate-websocket'; - -describe('isValidOrigin', () => { - const options = { - localAddress: 'http://localhost:6006', - networkAddress: 'http://192.168.1.100:6006', - } as any; - - /** When allowedHosts is set to [], only local/network origins are allowed (no default allow-all). */ - const strictOptions = { ...options, allowedHosts: [] } as any; - - it('returns true for exact local address match', () => { - expect(isValidOrigin('http://localhost:6006', options)).toBe(true); - }); - - it('returns true for network address match', () => { - expect(isValidOrigin('http://192.168.1.100:6006', options)).toBe(true); - }); - - it('returns true for localhost origin', () => { - expect(isValidOrigin('http://localhost:6006', options)).toBe(true); - }); - - it('returns true for 127.0.0.1 origin', () => { - expect(isValidOrigin('http://127.0.0.1:6006', options)).toBe(true); - }); - - // TODO: Change default to [] in SB11 - it('when allowedHosts is undefined (default true), allows any origin', () => { - expect(isValidOrigin('http://malicious-site.com', options)).toBe(true); - expect(isValidOrigin('https://any-origin.example.com', options)).toBe(true); - }); - - it('when allowedHosts is true, allows any origin', () => { - const allowAllOptions = { ...options, allowedHosts: true } as any; - expect(isValidOrigin('http://malicious-site.com', allowAllOptions)).toBe(true); - expect(isValidOrigin('https://any-origin.example.com', allowAllOptions)).toBe(true); - }); - - it('returns false for different origin when allowedHosts is empty', () => { - expect(isValidOrigin('http://malicious-site.com', strictOptions)).toBe(false); - }); - - it('returns true for localhost with different port (host-validation-middleware allows localhost)', () => { - expect(isValidOrigin('http://localhost:8080', strictOptions)).toBe(true); - }); - - it('returns true for localhost with different protocol (host-validation-middleware allows localhost)', () => { - expect(isValidOrigin('https://localhost:6006', strictOptions)).toBe(true); - }); - - it('returns false for undefined origin when not allow-all', () => { - expect(isValidOrigin(undefined, strictOptions)).toBe(false); - }); - - it('returns false for null origin when not allow-all', () => { - expect(isValidOrigin(null as any, strictOptions)).toBe(false); - }); - - it('returns true when localAddress is missing but hostname is localhost (host-validation-middleware allows localhost)', () => { - const optionsWithoutLocal = { - networkAddress: 'http://192.168.1.100:6006', - allowedHosts: [], - } as any; - expect(isValidOrigin('http://localhost:6006', optionsWithoutLocal)).toBe(true); - }); - - it('handles https protocol correctly', () => { - const httpsOptions = { - localAddress: 'https://localhost:6006', - networkAddress: 'https://192.168.1.100:6006', - } as any; - expect(isValidOrigin('https://localhost:6006', httpsOptions)).toBe(true); - expect(isValidOrigin('https://192.168.1.100:6006', httpsOptions)).toBe(true); - const strictHttpsOptions = { ...httpsOptions, allowedHosts: [] } as any; - expect(isValidOrigin('http://localhost:6006', strictHttpsOptions)).toBe(true); - }); - - it('handles network address without port', () => { - const optionsWithoutNetwork = { - localAddress: 'http://localhost:6006', - } as any; - expect(isValidOrigin('http://localhost:6006', optionsWithoutNetwork)).toBe(true); - const strictNoNetwork = { ...optionsWithoutNetwork, allowedHosts: [] } as any; - expect(isValidOrigin('http://192.168.1.100:6006', strictNoNetwork)).toBe(true); - }); - - it('returns true for different network IP (host-validation-middleware allows any IPv4)', () => { - expect(isValidOrigin('http://10.0.0.1:6006', strictOptions)).toBe(true); - }); - - it('handles origin with path', () => { - // Origin header should not include path, but if it does, we should still validate correctly - expect(isValidOrigin('http://localhost:6006/path', options)).toBe(true); - }); - - it('handles origin with query parameters', () => { - // Origin header should not include query, but if it does, we should still validate correctly - expect(isValidOrigin('http://localhost:6006?query=value', options)).toBe(true); - }); - - it('returns true when request hostname is in allowedHosts', () => { - const optionsWithAllowed = { - ...options, - allowedHosts: ['my-app.example.com', 'other.example.com'], - } as any; - expect(isValidOrigin('https://my-app.example.com', optionsWithAllowed)).toBe(true); - expect(isValidOrigin('https://other.example.com', optionsWithAllowed)).toBe(true); - }); - - it('returns false when request hostname is not in allowedHosts and not local/network', () => { - const optionsWithAllowed = { - localAddress: 'http://localhost:6006', - networkAddress: 'http://192.168.1.100:6006', - allowedHosts: ['my-app.example.com'], - } as any; - expect(isValidOrigin('https://other-site.com', optionsWithAllowed)).toBe(false); - }); - - it('allowedHosts does not override default local/network checks', () => { - const optionsWithAllowed = { - ...options, - allowedHosts: ['my-app.example.com'], - } as any; - expect(isValidOrigin('http://localhost:6006', optionsWithAllowed)).toBe(true); - expect(isValidOrigin('http://192.168.1.100:6006', optionsWithAllowed)).toBe(true); - }); - - it('empty allowedHosts does not allow arbitrary origins', () => { - const optionsWithEmptyAllowed = { - localAddress: 'http://localhost:6006', - allowedHosts: [], - } as any; - expect(isValidOrigin('http://localhost:6006', optionsWithEmptyAllowed)).toBe(true); - expect(isValidOrigin('https://arbitrary.com', optionsWithEmptyAllowed)).toBe(false); - }); - - it('when host is 0.0.0.0 and allowedHosts is empty, permits all origins', () => { - const optionsZeroHost = { - host: '0.0.0.0', - allowedHosts: [], - } as any; - expect(isValidOrigin('http://malicious-site.com', optionsZeroHost)).toBe(true); - expect(isValidOrigin('https://any-origin.example.com', optionsZeroHost)).toBe(true); - }); - - it('when host is 0.0.0.0 but allowedHosts is not empty, does not permit arbitrary origins', () => { - const optionsZeroHostWithAllowed = { - host: '0.0.0.0', - localAddress: 'http://localhost:6006', - allowedHosts: ['my-app.example.com'], - } as any; - expect(isValidOrigin('http://malicious-site.com', optionsZeroHostWithAllowed)).toBe(false); - expect(isValidOrigin('https://my-app.example.com', optionsZeroHostWithAllowed)).toBe(true); - }); - - it('when allowedHosts has hostnames, matches by hostname (host-validation-middleware ignores port in allowedHosts)', () => { - const optionsWithAllowed = { - localAddress: 'http://localhost:6006', - allowedHosts: ['my-app.example.com', 'other.example.com'], - } as any; - expect(isValidOrigin('https://my-app.example.com:8443', optionsWithAllowed)).toBe(true); - expect(isValidOrigin('http://other.example.com:8080', optionsWithAllowed)).toBe(true); - expect(isValidOrigin('https://other-site.com:8080', optionsWithAllowed)).toBe(false); - }); -}); - -describe('isValidToken', () => { - const validToken = 'test-token-123'; - - it('returns true for matching tokens', () => { - expect(isValidToken(validToken, validToken)).toBe(true); - }); - - it('returns false for non-matching tokens', () => { - expect(isValidToken('wrong-token', validToken)).toBe(false); - }); - - it('returns false for null token', () => { - expect(isValidToken(null, validToken)).toBe(false); - }); - - it('returns false for undefined token', () => { - expect(isValidToken(undefined as any, validToken)).toBe(false); - }); - - it('returns false for empty token', () => { - expect(isValidToken('', validToken)).toBe(false); - }); - - it('returns false when expected token is null', () => { - expect(isValidToken(validToken, null as any)).toBe(false); - }); - - it('returns false when expected token is undefined', () => { - expect(isValidToken(validToken, undefined as any)).toBe(false); - }); - - it('returns false for empty expected token', () => { - expect(isValidToken(validToken, '')).toBe(false); - }); - - it('returns false for tokens with different lengths', () => { - expect(isValidToken('short', 'much-longer-token')).toBe(false); - }); - - it('handles special characters in tokens', () => { - const specialToken = 'token-with-special-chars-!@#$%^&*()'; - expect(isValidToken(specialToken, specialToken)).toBe(true); - expect(isValidToken(specialToken, 'different-token')).toBe(false); - }); - - it('handles unicode characters in tokens', () => { - const unicodeToken = 'token-with-unicode-๐Ÿš€-ๆต‹่ฏ•'; - expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); - expect(isValidToken(unicodeToken, 'different-token')).toBe(false); - }); -}); diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index a8a29af1b9b4..7a3f1e23cc77 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -7,8 +7,8 @@ import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; -import { type HostValidationOptions } from './getHostValidationMiddleware'; -import { isValidOrigin, isValidToken } from './validate-websocket'; +import { type HostValidationOptions, isValidHost } from './getHostValidationMiddleware'; +import { isValidToken } from './validate-token'; type Server = NonNullable[0]>['server']>; @@ -37,7 +37,8 @@ export class ServerChannelTransport { return; } - if (!isValidOrigin(request.headers.origin, options)) { + const originHost = request.headers.origin && new URL(request.headers.origin).host; + if (!isValidHost(originHost, options)) { throw new Error('Invalid websocket origin'); } diff --git a/code/core/src/core-server/utils/getHostValidationMiddleware.ts b/code/core/src/core-server/utils/getHostValidationMiddleware.ts index 2e674c75d8c3..2a40296b60aa 100644 --- a/code/core/src/core-server/utils/getHostValidationMiddleware.ts +++ b/code/core/src/core-server/utils/getHostValidationMiddleware.ts @@ -14,6 +14,40 @@ export type HostValidationOptions = { networkAddress?: string; }; +/** + * Validates a host (Host headerโ€“shaped string, e.g. "localhost:6006") against known local/network + * addresses and allowed hosts. Callers must pass host-shaped input; normalize from an origin URL + * (e.g. new URL(origin).host) before invoking if needed. + * + * @param host - The host to validate (hostname or "hostname:port"). + * @param options - The builder options. + * @returns `true` if the host is valid, `false` otherwise. + */ +export const isValidHost = (host: string | undefined, options: HostValidationOptions): boolean => { + const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; + if (allowedHosts === true) { + return true; + } + if (!host) { + return false; + } + + try { + // Setting host to 0.0.0.0 binds to all hosts, which implies allowing all hosts. + // This is common in containerized environments like Docker. + if (options.host === '0.0.0.0' && allowedHosts.length === 0) { + return true; + } + return isHostAllowed(host, [ + ...allowedHosts, + ...(options.localAddress ? [new URL(options.localAddress).host] : []), + ...(options.networkAddress ? [new URL(options.networkAddress).host] : []), + ]); + } catch { + return false; + } +}; + /** * Validates the Host header against known local/network addresses and allowed hosts. Requests with * no Host header (e.g. same-origin navigation, GET from address bar) are not allowed. @@ -24,9 +58,9 @@ export function getHostValidationMiddleware( return (req, res, next) => { const host = req.headers.host; const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; - if (allowedHosts !== true && (!host || !isHostAllowed(host, allowedHosts))) { + if (allowedHosts !== true && !isValidHost(host, options)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); - res.end('Invalid origin'); + res.end('Invalid host'); return; } next(); diff --git a/code/core/src/core-server/utils/validate-token.ts b/code/core/src/core-server/utils/validate-token.ts new file mode 100644 index 000000000000..7bd26ce91669 --- /dev/null +++ b/code/core/src/core-server/utils/validate-token.ts @@ -0,0 +1,20 @@ +import { timingSafeEqual } from 'node:crypto'; + +/** + * Validates a secret token using constant-time comparison to prevent timing attacks. + * + * @returns `true` if tokens match, `false` otherwise + */ +export function isValidToken(requestToken: string | null, expectedToken: string): boolean { + if (!requestToken || !expectedToken) { + return false; + } + + const a = new Uint8Array(Buffer.from(requestToken, 'utf8')); + const b = new Uint8Array(Buffer.from(expectedToken, 'utf8')); + try { + return a.length === b.length && timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/code/core/src/core-server/utils/validate-websocket.ts b/code/core/src/core-server/utils/validate-websocket.ts deleted file mode 100644 index 16e9958f106b..000000000000 --- a/code/core/src/core-server/utils/validate-websocket.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { timingSafeEqual } from 'node:crypto'; - -import { isHostAllowed } from 'host-validation-middleware'; - -import { DEFAULT_ALLOWED_HOSTS, type HostValidationOptions } from './getHostValidationMiddleware'; - -/** - * Validates a request Origin against known local/network addresses and allowed hosts. - * - * @param requestOrigin - The origin header value to validate. - * @param options - The builder options. - * @returns `true` if the origin is valid, `false` otherwise. - */ -export const isValidOrigin = ( - requestOrigin: string | undefined, - options: HostValidationOptions -): boolean => { - const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; - if (allowedHosts === true) { - return true; - } - if (!requestOrigin) { - return false; - } - - try { - const requestUrl = new URL(requestOrigin); - const localUrl = options.localAddress && new URL(options.localAddress); - const networkUrl = options.networkAddress && new URL(options.networkAddress); - - if (localUrl && localUrl.origin === requestUrl.origin) { - return true; - } - if (networkUrl && networkUrl.origin === requestUrl.origin) { - return true; - } - if (options.host === '0.0.0.0' && allowedHosts.length === 0) { - return true; - } - return isHostAllowed(requestUrl.host, allowedHosts); - } catch { - return false; - } -}; - -/** - * Validates a secret token using constant-time comparison to prevent timing attacks. - * - * @returns `true` if tokens match, `false` otherwise - */ -export function isValidToken(requestToken: string | null, expectedToken: string): boolean { - if (!requestToken || !expectedToken) { - return false; - } - - const a = new Uint8Array(Buffer.from(requestToken, 'utf8')); - const b = new Uint8Array(Buffer.from(expectedToken, 'utf8')); - try { - return a.length === b.length && timingSafeEqual(a, b); - } catch { - return false; - } -} From 0b1085fc5f6a045c36c8fd03371fa6613a802ec1 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 27 Feb 2026 14:15:17 +0100 Subject: [PATCH 27/56] Update docs/api/doc-blocks/doc-block-tableofcontents.mdx Co-authored-by: jonniebigodes --- docs/api/doc-blocks/doc-block-tableofcontents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/doc-blocks/doc-block-tableofcontents.mdx b/docs/api/doc-blocks/doc-block-tableofcontents.mdx index f8743da6fa80..c125c98041ee 100644 --- a/docs/api/doc-blocks/doc-block-tableofcontents.mdx +++ b/docs/api/doc-blocks/doc-block-tableofcontents.mdx @@ -5,7 +5,7 @@ sidebar: title: TableOfContents --- -The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to quickly navigate between sections. It appears as a fixed sidebar on the right side of the documentation page and is hidden on smaller screens (below 768px). +The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to navigate between sections quickly. It appears as a fixed sidebar on the right side of the documentation page and is hidden on smaller screens (below 768px). The table of contents is enabled and configured via the `docs.toc` [parameter](../../writing-stories/parameters.mdx) rather than being added directly to MDX files. When enabled, it is automatically rendered alongside the page content by [Storybook's docs container](../doc-blocks/doc-block-meta.mdx). From 862feb6c4850fa659dd2183c01ac290dd771d349 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 27 Feb 2026 14:15:25 +0100 Subject: [PATCH 28/56] Update docs/api/doc-blocks/doc-block-tableofcontents.mdx Co-authored-by: jonniebigodes --- docs/api/doc-blocks/doc-block-tableofcontents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/doc-blocks/doc-block-tableofcontents.mdx b/docs/api/doc-blocks/doc-block-tableofcontents.mdx index c125c98041ee..3c6c7b243ba8 100644 --- a/docs/api/doc-blocks/doc-block-tableofcontents.mdx +++ b/docs/api/doc-blocks/doc-block-tableofcontents.mdx @@ -51,7 +51,7 @@ Type: `boolean` Default: `false` -When `true`, hides the table of contents for the documentation page. A hidden (empty) container is still rendered to preserve the page layout. +When `true`, it hides the table of contents for the documentation page. A hidden (empty) container is still rendered to preserve the page layout. ### `headingSelector` From 78dfb5894caca08158cf9c95e78402cc08d827b9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 27 Feb 2026 14:15:32 +0100 Subject: [PATCH 29/56] Update docs/api/doc-blocks/doc-block-tableofcontents.mdx Co-authored-by: jonniebigodes --- docs/api/doc-blocks/doc-block-tableofcontents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/doc-blocks/doc-block-tableofcontents.mdx b/docs/api/doc-blocks/doc-block-tableofcontents.mdx index 3c6c7b243ba8..96b282affee7 100644 --- a/docs/api/doc-blocks/doc-block-tableofcontents.mdx +++ b/docs/api/doc-blocks/doc-block-tableofcontents.mdx @@ -75,7 +75,7 @@ Type: `string | null | ReactElement` Default: `'Table of contents'` (visually hidden) -Text or element to display as the title above the table of contents. Set to `null` to render no title. When a string is provided it is rendered as a visually hidden `

` by default; pass a non-empty string to make it visible. +Text or element to display as the title above the table of contents. Set to `null` to render no title. When a string is provided, it is rendered as a visually hidden `

` by default; pass a non-empty string to make it visible. ### `unsafeTocbotOptions` From 88ba63bcfe57518d8ab399688361a154a288b5bf Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 27 Feb 2026 15:49:19 +0100 Subject: [PATCH 30/56] Resolve comments --- code/builders/builder-vite/src/vite-server.ts | 5 ++++- code/core/src/core-server/utils/get-server-channel.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index 896eebf9aa2e..2eab07ca2529 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -40,7 +40,10 @@ export async function createViteServer(options: Options, devServer: Server) { }; // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments - if (options.host === '0.0.0.0' && !config.server.allowedHosts) { + if ( + options.host === '0.0.0.0' && + (!allowedHosts || (Array.isArray(allowedHosts) && allowedHosts.length === 0)) + ) { config.server.allowedHosts = true; } diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 7a3f1e23cc77..2050bf9d71e7 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -6,6 +6,7 @@ import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; +import { logger } from '../../node-logger'; import { UniversalStore } from '../../shared/universal-store'; import { type HostValidationOptions, isValidHost } from './getHostValidationMiddleware'; import { isValidToken } from './validate-token'; @@ -32,7 +33,7 @@ export class ServerChannelTransport { server.on('upgrade', (request: IncomingMessage, socket, head) => { try { - const url = request.url && new URL(request.url, request.headers.origin); + const url = request.url && new URL(request.url, options.localAddress); if (!url || url.pathname !== '/storybook-server-channel') { return; } @@ -51,7 +52,7 @@ export class ServerChannelTransport { this.socket.emit('connection', ws, request); }); } catch (error) { - console.warn('Rejecting WebSocket connection:', error); + logger.warn(`Rejecting WebSocket connection: ${error}`); socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); } From 307e283e5a7d0c71a9f93f2a4ee0b2dd14721fb6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 27 Feb 2026 16:19:08 +0100 Subject: [PATCH 31/56] Remove TypeScript error suppression for private method access in server-channel tests --- .../src/core-server/utils/__tests__/server-channel.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index 4da1e7004e11..9128df924339 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -106,7 +106,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).toHaveBeenCalledWith( @@ -186,7 +185,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).toHaveBeenCalledWith( @@ -210,7 +208,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).toHaveBeenCalledWith( @@ -241,7 +238,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).not.toHaveBeenCalled(); @@ -271,7 +267,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).not.toHaveBeenCalled(); @@ -301,7 +296,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); // Should not call handleUpgrade for wrong path From 5ffc0e1703a624150ddaff7725e0c8bb901cfb8c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 27 Feb 2026 16:30:58 +0100 Subject: [PATCH 32/56] UI: Rework edit button with instructions from MA --- .../docs/src/blocks/controls/Object.tsx | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index 4b6ddbb2fddf..de2c6d81ace4 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { type Theme, srOnlyStyles, styled, useTheme } from 'storybook/theming'; +import { type Theme, styled, useTheme } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; @@ -22,11 +22,6 @@ const Wrapper = styled.div(({ theme }) => ({ isolation: 'isolate', gap: 8, - // Enable container queries for child responsive styles - '@supports (container-type: inline-size)': { - containerType: 'inline-size', - }, - '.rejt-tree': { flex: 1, marginLeft: '1rem', @@ -125,27 +120,9 @@ const Input = styled.input(({ theme, placeholder }) => ({ })); const RawButton = styled(ToggleButton)({ - position: 'absolute', - zIndex: 2, - top: 2, - right: 2, - gap: '4px', - - // Container query: respond to component width (WCAG 2.1 Reflow) - '@container (max-width: 400px)': { - position: 'static', - alignSelf: 'flex-start', - order: 2, - '& > span': srOnlyStyles, - }, - - // Fallback for browsers without container query support - '@media (max-width: 400px)': { - position: 'static', - alignSelf: 'flex-start', - order: 2, - '& > span': srOnlyStyles, - }, + alignSelf: 'flex-start', + order: 2, + marginRight: -10, }); const RawInput = styled(Form.Textarea)(({ theme }) => ({ @@ -222,7 +199,7 @@ export const ObjectControl: FC = ({ name, value, onChange, argType const onForceVisible = useCallback(() => { onChange({}); setForceVisible(true); - }, [setForceVisible]); + }, [onChange, setForceVisible]); const htmlElRef = useRef(null); useEffect(() => { @@ -274,14 +251,16 @@ export const ObjectControl: FC = ({ name, value, onChange, argType { e.preventDefault(); setShowRaw((isRaw) => !isRaw); }} + variant="ghost" + padding="small" + size="small" > - Edit JSON )} {!showRaw ? ( From 9a50bb5fa739561b65d8455c667d2c6c1e6860a5 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 27 Feb 2026 16:42:40 +0100 Subject: [PATCH 33/56] Remove bad config --- code/.storybook/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 9fc833e56f26..05b99156056f 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -172,7 +172,6 @@ const config = defineMain({ target: BROWSER_TARGETS, }, server: { - allowedHosts: ['nonstatutory-hydrodynamically-twila.ngrok-free.dev'], watch: { // Something odd happens with tsconfig and nx which causes Storybook to keep reloading, so we ignore them ignored: ['**/.nx/cache/**', '**/tsconfig.json'], From 72138ab2265e02bbf0db96010bcdbf3288e35517 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 21 Jan 2026 14:21:57 +0100 Subject: [PATCH 34/56] Docs: Mention React version requirement for addons --- docs/addons/writing-addons.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/addons/writing-addons.mdx b/docs/addons/writing-addons.mdx index 07cc0099e087..6850ad3129ed 100644 --- a/docs/addons/writing-addons.mdx +++ b/docs/addons/writing-addons.mdx @@ -30,6 +30,10 @@ The addon built in this guide is a UI-based addon, specifically a [toolbar](./ad {/* prettier-ignore-end */} + + Addons with a UI must use the same React version as Storybook does. If your component library uses a different React version, you must use addons that are built and published as standalone packages. We'll cover how to do this with the [Addon Kit](https://github.com/storybookjs/addon-kit). + + ## Setup To create your first addon, you're going to use the [Addon Kit](https://github.com/storybookjs/addon-kit), a ready-to-use template featuring all the required building blocks, dependencies and configurations to help you get started building your addon. In the Addon Kit repository, click the **Use this template** button to create a new repository based on the Addon Kit's code. From 48bea3f01bcffe97de01eca36c3590061a76fb8c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 27 Feb 2026 15:57:13 +0100 Subject: [PATCH 35/56] Apply suggestion from @jonniebigodes Co-authored-by: jonniebigodes --- docs/addons/writing-addons.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/addons/writing-addons.mdx b/docs/addons/writing-addons.mdx index 6850ad3129ed..9fe4f23b0e4f 100644 --- a/docs/addons/writing-addons.mdx +++ b/docs/addons/writing-addons.mdx @@ -31,7 +31,7 @@ The addon built in this guide is a UI-based addon, specifically a [toolbar](./ad {/* prettier-ignore-end */} - Addons with a UI must use the same React version as Storybook does. If your component library uses a different React version, you must use addons that are built and published as standalone packages. We'll cover how to do this with the [Addon Kit](https://github.com/storybookjs/addon-kit). +Addons with a UI must use the same React version as Storybook. If your component library uses a different React version, you must use addons that are built and published as standalone packages. We'll cover how to do this with the [Addon Kit](https://github.com/storybookjs/addon-kit). ## Setup From 340802e244ab9d0590f9bd57ce076cd820aef229 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 27 Feb 2026 17:42:15 +0100 Subject: [PATCH 36/56] Update getHostValidationMiddleware tests to use empty allowedHosts array because localhost is already included by default - Modified test cases to set allowedHosts to an empty array instead of ['localhost']. - Ensured that the middleware behavior is correctly validated with the new configuration. --- .../utils/__tests__/getHostValidationMiddleware.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts index 431421de3b10..5df097be2e49 100644 --- a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts +++ b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts @@ -33,7 +33,7 @@ describe('getHostValidationMiddleware', () => { it('returns 403 when Host is invalid (strict allowedHosts)', () => { const middleware = getHostValidationMiddleware({ host: 'localhost', - allowedHosts: ['localhost'], + allowedHosts: [], localAddress: 'http://localhost:6006', networkAddress: 'http://192.168.1.100:6006', }); @@ -51,7 +51,7 @@ describe('getHostValidationMiddleware', () => { it('calls next() when Host is valid (matches localAddress)', () => { const middleware = getHostValidationMiddleware({ host: 'localhost', - allowedHosts: ['localhost'], + allowedHosts: [], localAddress: 'http://localhost:6006', networkAddress: 'http://192.168.1.100:6006', }); @@ -68,7 +68,7 @@ describe('getHostValidationMiddleware', () => { it('calls next() when Host matches networkAddress', () => { const middleware = getHostValidationMiddleware({ host: '0.0.0.0', - allowedHosts: ['localhost'], + allowedHosts: [], localAddress: 'http://localhost:6006', networkAddress: 'http://192.168.1.100:6006', }); @@ -102,7 +102,7 @@ describe('getHostValidationMiddleware', () => { it('returns 403 when Host header is absent (allowedHosts not true)', () => { const middleware = getHostValidationMiddleware({ host: 'localhost', - allowedHosts: ['localhost'], + allowedHosts: [], localAddress: 'http://localhost:6006', }); const req = createMockRequest({}); From e2d16ff84ad86885b37fe2509ac8fceee6d21c8c Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Sat, 28 Feb 2026 12:18:25 +0900 Subject: [PATCH 37/56] Revert "feat: Introduce a `hidden` prop to the `Button` component and utilize it for the zoom reset button." This reverts commit 547521bbfc16b1e4ff05090cc8f4dfd094183b8b. --- .../components/components/Button/Button.tsx | 7 +------ .../manager/components/preview/tools/zoom.tsx | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index a88d58c6ceb7..dbf24c1622df 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -60,7 +60,6 @@ export const Button = forwardRef( variant = 'outline', padding = 'medium', disabled = false, - hidden = false, readOnly = false, active, onClick, @@ -139,8 +138,7 @@ export const Button = forwardRef( variant={variant} size={size} padding={padding} - disabled={disabled || hidden || readOnly} - hidden={hidden} + disabled={disabled || readOnly} readOnly={readOnly} active={active} animating={isAnimating} @@ -168,7 +166,6 @@ const StyledButton = styled('button', { variant?: 'outline' | 'solid' | 'ghost'; active?: boolean; disabled?: boolean; - hidden?: boolean; readOnly?: boolean; animating?: boolean; animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; @@ -178,7 +175,6 @@ const StyledButton = styled('button', { variant, size, disabled, - hidden, readOnly, active, animating, @@ -221,7 +217,6 @@ const StyledButton = styled('button', { whiteSpace: 'nowrap', userSelect: 'none', opacity: disabled && !readOnly ? 0.5 : 1, - visibility: hidden ? 'hidden' : 'visible', margin: 0, fontSize: `${theme.typography.size.s1}px`, fontWeight: theme.typography.weight.bold, diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 9dd462f4e6e9..2ca48a5c5efb 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -81,15 +81,16 @@ export const Zoom = memo<{ } after={ - + value !== INITIAL_ZOOM_LEVEL && ( + zoomTo(INITIAL_ZOOM_LEVEL)} + ariaLabel="Reset zoom" + > + + + ) } value={`${Math.round(value * 100)}%`} minValue={1} From db260457c6dbe42f044f5046ee58b5c274583b18 Mon Sep 17 00:00:00 2001 From: ia319 Date: Sat, 28 Feb 2026 17:00:54 +0800 Subject: [PATCH 38/56] fix(manager-api): update refs sequentially in experimental_setFilter --- code/core/src/manager-api/modules/stories.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 157817fc7909..30a9b769eef9 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -705,9 +705,9 @@ export const init: ModuleFn = ({ await api.setIndex(index); const refs = await fullAPI.getRefs(); - Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { - fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); - }); + for (const [refId, { internal_index, ...ref }] of Object.entries(refs)) { + await fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); + } provider.channel?.emit(SET_FILTER, { id }); }, From 2952d6b8232b7dbf87ab769e69973c6159d74318 Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Sun, 1 Mar 2026 00:19:03 +0900 Subject: [PATCH 39/56] Refactor: Replace conditional rendering of the zoom reset button with conditional CSS visibility using a new styled component. --- .../manager/components/preview/tools/zoom.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 2ca48a5c5efb..11e2baf322a3 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -19,6 +19,12 @@ const ZoomButton = styled(Button)({ minWidth: 48, }); +const ZoomResetButton = styled(ActionList.Button)<{ $isInitialValue: boolean }>( + ({ $isInitialValue }) => ({ + visibility: $isInitialValue ? 'hidden' : undefined, + }) +); + const Context = createContext({ value: INITIAL_ZOOM_LEVEL, set: (v: number) => {} }); const ZoomInput = styled(NumericInput)({ @@ -81,16 +87,16 @@ export const Zoom = memo<{ } after={ - value !== INITIAL_ZOOM_LEVEL && ( - zoomTo(INITIAL_ZOOM_LEVEL)} - ariaLabel="Reset zoom" - > - - - ) + zoomTo(INITIAL_ZOOM_LEVEL)} + ariaLabel="Reset zoom" + aria-hidden={value === INITIAL_ZOOM_LEVEL} + > + + } value={`${Math.round(value * 100)}%`} minValue={1} From 93f30d93f85ecc3fd6fb8c43ae0e7baa025ca412 Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Sun, 1 Mar 2026 13:02:43 +0900 Subject: [PATCH 40/56] feat: use `hidden` attribute instead of `aria-hidden` for the zoom reset button when at initial zoom level --- code/core/src/manager/components/preview/tools/zoom.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 11e2baf322a3..3468bf8a5fe7 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -90,10 +90,10 @@ export const Zoom = memo<{ From 2dc2b049c9721fd64ae068392a3d67ff28c76ae2 Mon Sep 17 00:00:00 2001 From: superLipbalm Date: Mon, 2 Mar 2026 13:21:51 +0900 Subject: [PATCH 41/56] Revert "feat: use `hidden` attribute instead of `aria-hidden` for the zoom reset button when at initial zoom level" This reverts commit 93f30d93f85ecc3fd6fb8c43ae0e7baa025ca412. --- code/core/src/manager/components/preview/tools/zoom.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 3468bf8a5fe7..11e2baf322a3 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -90,10 +90,10 @@ export const Zoom = memo<{ From 06617d7e77216d166c4cdc58d537b0ee4f32f284 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 9 Jan 2026 14:50:10 +0100 Subject: [PATCH 42/56] Allow more direct operation of Zoom tool --- .../components/preview/tools/zoom.stories.tsx | 86 ++++++++++++++++++- .../manager/components/preview/tools/zoom.tsx | 41 ++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index 75a5eec411ca..6a75d16626f6 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import type { StoryContext } from '@storybook/react-vite'; -import { fn, screen, within } from 'storybook/test'; +import { Simulate } from 'react-dom/test-utils'; +import { expect, fn, screen, waitFor, within } from 'storybook/test'; import preview from '../../../../../../.storybook/preview'; import { Zoom } from './zoom'; @@ -30,6 +31,7 @@ const meta = preview.meta({ zoomIn: () => setValue(value + 0.5), zoomOut: () => setValue(value - 0.5), zoomTo: setValue, + zoomBy: (delta: number) => setValue(Math.max(0.01, value + delta)), }} /> ); @@ -80,3 +82,85 @@ export const MinZoom = meta.story({ value: 0.25, }, }); + +export const ArrowUpKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[ArrowUp]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('101%'); + }, +}); + +export const ArrowDownKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + await zoom.focus(); + await userEvent.keyboard('[ArrowDown]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('99%'); + }, +}); + +export const PageUpKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[PageUp]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('150%'); + }, +}); + +export const PageDownKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[PageDown]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('50%'); + }, +}); + +export const HomeKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[Home]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('400%'); + }, +}); + +export const EndKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[End]'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('25%'); + }, +}); + +export const WheelUp = meta.story({ + args: {}, + play: async ({ canvas }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + Simulate.wheel(zoom, { deltaY: -100 }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('150%') + ); + }, +}); + +export const WheelDown = meta.story({ + args: {}, + play: async ({ canvas }) => { + const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + Simulate.wheel(zoom, { deltaY: 100 }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('50%') + ); + }, +}); diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 8ae440d7f092..66ab1b67eb25 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -56,7 +56,8 @@ export const Zoom = memo<{ zoomIn: () => void; zoomOut: () => void; zoomTo: (value: number) => void; -}>(function Zoom({ value, zoomIn, zoomOut, zoomTo }) { + zoomBy: (delta: number) => void; +}>(function Zoom({ value, zoomIn, zoomOut, zoomTo, zoomBy }) { const inputRef = useRef(null); return ( @@ -149,6 +150,35 @@ export const Zoom = memo<{ variant="ghost" ariaLabel="Change zoom level" pressed={value !== INITIAL_ZOOM_LEVEL} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + zoomBy(-0.01); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + zoomBy(0.01); + e.preventDefault(); + } else if (e.key === 'PageDown') { + zoomOut(); + e.preventDefault(); + } else if (e.key === 'PageUp') { + zoomIn(); + e.preventDefault(); + } else if (e.key === 'Home') { + zoomTo(ZOOM_LEVELS[ZOOM_LEVELS.length - 1]); + e.preventDefault(); + } else if (e.key === 'End') { + zoomTo(ZOOM_LEVELS[0]); + e.preventDefault(); + } + }} + onWheel={(e) => { + if (e.deltaY < 0) { + zoomIn(); + } else if (e.deltaY > 0) { + zoomOut(); + } + e.preventDefault(); + }} > {Math.round(value * 100)}% @@ -176,6 +206,13 @@ const ZoomWrapper = memo<{ } }, [set, value]); + const zoomBy = useCallback( + (delta: number) => { + set(Math.max(0.01, value + delta)); + }, + [set, value] + ); + const zoomTo = useCallback( (value: number) => { set(value); @@ -210,7 +247,7 @@ const ZoomWrapper = memo<{ }); }, [api, zoomIn, zoomOut, zoomTo]); - return ; + return ; }); export const zoomTool: Addon_BaseType = { From 3bec84516c943870cfe31c8428d643c284106ae0 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 09:43:06 +0100 Subject: [PATCH 43/56] Fix type issues in story file --- .../src/manager/components/preview/tools/zoom.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index 6a75d16626f6..dbb2c120f7a0 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -2,8 +2,7 @@ import { useState } from 'react'; import type { StoryContext } from '@storybook/react-vite'; -import { Simulate } from 'react-dom/test-utils'; -import { expect, fn, screen, waitFor, within } from 'storybook/test'; +import { expect, fireEvent, fn, screen, waitFor, within } from 'storybook/test'; import preview from '../../../../../../.storybook/preview'; import { Zoom } from './zoom'; @@ -21,6 +20,7 @@ const meta = preview.meta({ zoomIn: fn(), zoomOut: fn(), zoomTo: fn(), + zoomBy: fn(), }, render: (args: Parameters[0]) => { const [value, setValue] = useState(args.value); @@ -147,7 +147,7 @@ export const WheelUp = meta.story({ args: {}, play: async ({ canvas }) => { const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); - Simulate.wheel(zoom, { deltaY: -100 }); + await fireEvent.wheel(zoom, { deltaY: -100 }); await waitFor(() => expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('150%') ); @@ -158,7 +158,7 @@ export const WheelDown = meta.story({ args: {}, play: async ({ canvas }) => { const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); - Simulate.wheel(zoom, { deltaY: 100 }); + await fireEvent.wheel(zoom, { deltaY: 100 }); await waitFor(() => expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('50%') ); From a0cf4dc3ce1c83de8c5e350e8ec060b25772f912 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 09:46:51 +0100 Subject: [PATCH 44/56] Adjust stories to new zoom range --- code/core/src/manager/components/preview/tools/zoom.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index dbb2c120f7a0..1375ae86d338 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -129,7 +129,7 @@ export const HomeKey = meta.story({ const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[Home]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('400%'); + expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('800%'); }, }); From e5f1a603cefcb9309b928deacf1ba0e6030fbddc Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 10:59:43 +0100 Subject: [PATCH 45/56] Fix Zoom stories --- .../components/preview/tools/zoom.stories.tsx | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index 1375ae86d338..abdce1a68004 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -86,81 +86,77 @@ export const MinZoom = meta.story({ export const ArrowUpKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[ArrowUp]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('101%'); + expect(screen.getByRole('switch', { name: 'Change zoom level' })).toHaveTextContent('101%'); }, }); export const ArrowDownKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); await zoom.focus(); await userEvent.keyboard('[ArrowDown]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('99%'); + expect(zoom).toHaveTextContent('99%'); }, }); export const PageUpKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[PageUp]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('150%'); + expect(zoom).toHaveTextContent('150%'); }, }); export const PageDownKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[PageDown]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('50%'); + expect(zoom).toHaveTextContent('50%'); }, }); export const HomeKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[Home]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('800%'); + expect(zoom).toHaveTextContent('800%'); }, }); export const EndKey = meta.story({ args: {}, play: async ({ canvas, userEvent }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[End]'); - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('25%'); + expect(zoom).toHaveTextContent('25%'); }, }); export const WheelUp = meta.story({ args: {}, play: async ({ canvas }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); await fireEvent.wheel(zoom, { deltaY: -100 }); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('150%') - ); + await waitFor(() => expect(zoom).toHaveTextContent('150%')); }, }); export const WheelDown = meta.story({ args: {}, play: async ({ canvas }) => { - const zoom = await canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); await fireEvent.wheel(zoom, { deltaY: 100 }); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Change zoom level' })).toHaveTextContent('50%') - ); + await waitFor(() => expect(zoom).toHaveTextContent('50%')); }, }); From e29915630ec817eef8fda4944355323910335401 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 11:28:11 +0100 Subject: [PATCH 46/56] refactor --- code/core/src/manager/components/preview/tools/zoom.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index abdce1a68004..28c7d8d4d042 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -89,7 +89,7 @@ export const ArrowUpKey = meta.story({ const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); zoom.focus(); await userEvent.keyboard('[ArrowUp]'); - expect(screen.getByRole('switch', { name: 'Change zoom level' })).toHaveTextContent('101%'); + expect(zoom).toHaveTextContent('101%'); }, }); From b7355d87686664ea6031c7c966b97efabc2e9934 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:32:19 +0000 Subject: [PATCH 47/56] Update CHANGELOG.md for v10.2.14 [skip ci] --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff4d1aca789..e47ac3da22d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 10.2.14 + +- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf! +- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000! + ## 10.2.13 - Addon Pseudo-states: Process all nested css rules - [#33605](https://github.com/storybookjs/storybook/pull/33605), thanks @hpohlmeyer! From 109f76ff4f4ccf984ac6bcfb5863ab687df1ba6d Mon Sep 17 00:00:00 2001 From: createhb21 Date: Mon, 2 Mar 2026 21:33:19 +0900 Subject: [PATCH 48/56] docs: add draftMode example to headers mock snippet --- docs/_snippets/nextjs-headers-mock.md | 56 +++++++++++++++++++-------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/docs/_snippets/nextjs-headers-mock.md b/docs/_snippets/nextjs-headers-mock.md index a21d6b337185..75cc713fb9fe 100644 --- a/docs/_snippets/nextjs-headers-mock.md +++ b/docs/_snippets/nextjs-headers-mock.md @@ -1,8 +1,8 @@ ```js filename="MyForm.stories.js" renderer="react" language="js" tabTitle="CSF 3" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // Replace your-framework with nextjs or nextjs-vite -import { cookies, headers } from '@storybook/your-framework/headers'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers'; import MyForm from './my-form'; @@ -12,16 +12,22 @@ export default { export const LoggedInEurope = { async beforeEach() { - // ๐Ÿ‘‡ Set mock cookies and headers ahead of rendering + // ๐Ÿ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // ๐Ÿ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }; ``` @@ -30,10 +36,10 @@ export const LoggedInEurope = { // Replace your-framework with nextjs or nextjs-vite import type { Meta, StoryObj } from '@storybook/your-framework'; -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // ๐Ÿ‘‡ Must include the `.mock` portion of filename to have mocks typed correctly -import { cookies, headers } from '@storybook/your-framework/headers.mock'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers.mock'; import MyForm from './my-form'; @@ -46,28 +52,34 @@ type Story = StoryObj; export const LoggedInEurope: Story = { async beforeEach() { - // ๐Ÿ‘‡ Set mock cookies and headers ahead of rendering + // ๐Ÿ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // ๐Ÿ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }; ``` ```ts filename="MyForm.stories.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; /* * Replace your-framework with nextjs or nextjs-vite * ๐Ÿ‘‡ Must include the `.mock` portion of filename to have mocks typed correctly */ -import { cookies, headers } from '@storybook/your-framework/headers.mock'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers.mock'; import preview from '../.storybook/preview'; @@ -79,16 +91,22 @@ const meta = preview.meta({ export const LoggedInEurope = meta.story({ async beforeEach() { - // ๐Ÿ‘‡ Set mock cookies and headers ahead of rendering + // ๐Ÿ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // ๐Ÿ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }); ``` @@ -96,10 +114,10 @@ export const LoggedInEurope = meta.story({ ```js filename="MyForm.stories.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // Replace your-framework with nextjs or nextjs-vite -import { cookies, headers } from '@storybook/your-framework/headers'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers'; import preview from '../.storybook/preview'; @@ -111,16 +129,22 @@ const meta = preview.meta({ export const LoggedInEurope = meta.story({ async beforeEach() { - // ๐Ÿ‘‡ Set mock cookies and headers ahead of rendering + // ๐Ÿ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // ๐Ÿ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }); ``` From 498b9135b52b7fb2deedb48207f3b6d9a9a3a433 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 17:13:01 +0100 Subject: [PATCH 49/56] Handle edge cases of zoomBy --- code/core/src/manager/components/preview/tools/zoom.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 66ab1b67eb25..875fc2cf305f 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -208,7 +208,9 @@ const ZoomWrapper = memo<{ const zoomBy = useCallback( (delta: number) => { - set(Math.max(0.01, value + delta)); + const min = ZOOM_LEVELS[0]; + const max = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + set(Math.max(min, Math.min(max, value + delta))); }, [set, value] ); From 804b239dad78c0950bb5b94bb93f8380061a7d3b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 3 Mar 2026 10:35:38 +0100 Subject: [PATCH 50/56] Remove test --- .../src/types/modules/core-common.test.ts | 21 ------------------- code/tsconfig.json | 1 - 2 files changed, 22 deletions(-) delete mode 100644 code/core/src/types/modules/core-common.test.ts diff --git a/code/core/src/types/modules/core-common.test.ts b/code/core/src/types/modules/core-common.test.ts deleted file mode 100644 index 08dba26bba21..000000000000 --- a/code/core/src/types/modules/core-common.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { StorybookConfig } from '@storybook/react-vite'; - -describe('TagsOptions', () => { - describe('defaultFilterSelection', () => { - it('accepts undefined', () => { - const config: StorybookConfig = { - stories: [], - framework: '@storybook/react-vite', - tags: { - unit: { - defaultFilterSelection: - process.env['NODE_ENV'] !== 'development' ? 'exclude' : undefined, - }, - }, - }; - expect(config).toBeDefined(); - }); - }); -}); diff --git a/code/tsconfig.json b/code/tsconfig.json index 1c63316a5066..a0979540cdc8 100644 --- a/code/tsconfig.json +++ b/code/tsconfig.json @@ -5,7 +5,6 @@ "baseUrl": ".", "customConditions": ["code"], "esModuleInterop": true, - // "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "ignoreDeprecations": "5.0", "incremental": false, From 2bdb54a73586347cdc6303ff119bb0cb778073be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:46:37 +0000 Subject: [PATCH 51/56] Fix prettier formatting in ConfigFile.ts Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com> --- code/core/src/csf-tools/ConfigFile.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 79aa5a9d886f..6e326004c82d 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -209,7 +209,10 @@ export class ConfigFile { if (t.isObjectExpression(decl.arguments[0])) { decl = decl.arguments[0]; break; - } else if (t.isMemberExpression(decl.callee) && t.isCallExpression(decl.callee.object)) { + } else if ( + t.isMemberExpression(decl.callee) && + t.isCallExpression(decl.callee.object) + ) { decl = decl.callee.object; } else { break; From 993bc00a8d836925b25cc8179aa74418a72377dc Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 3 Mar 2026 12:36:40 +0100 Subject: [PATCH 52/56] Remove unused 'server' preset and its interface --- code/core/src/types/modules/core-common.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index e672c219a614..565af073fa8e 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -113,11 +113,6 @@ export interface Presets { config?: StorybookConfigRaw['staticDirs'], args?: any ): Promise; - apply( - extension: 'server', - config?: StorybookConfigRaw['server'], - args?: any - ): Promise; /** The second and third parameter are not needed. And make type inference easier. */ apply(extension: T): Promise; @@ -344,14 +339,6 @@ export interface TestBuildFlags { esbuildMinify?: boolean; } -export interface ServerConfig { - /** - * Enable hostname validation for WebSocket connections. Set to `[]` to disallow all hosts except - * known local/network address, or `['*']` to allow all hosts. - */ - allowedHosts?: string[]; -} - export interface TestBuildConfig { test?: TestBuildFlags; } @@ -536,8 +523,6 @@ export interface StorybookConfigRaw { experimentalCodeExamples?: boolean; }; - server?: ServerConfig; - build?: TestBuildConfig; stories: StoriesEntry[]; From dd5bfac3a453eb449409498cabd5627b98b04f5f Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 3 Mar 2026 14:54:59 +0100 Subject: [PATCH 53/56] Show allowed hosts in starup information --- code/core/src/core-server/build-dev.ts | 1 + .../utils/output-startup-information.ts | 26 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 762a12aa97c1..bba61a8f6ddd 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -320,6 +320,7 @@ export async function buildDevStandalone( name, address: localAddress, networkAddress, + allowedHosts, managerTotalTime, previewTotalTime, }); diff --git a/code/core/src/core-server/utils/output-startup-information.ts b/code/core/src/core-server/utils/output-startup-information.ts index f86cf5d050cd..24c80bb1f42b 100644 --- a/code/core/src/core-server/utils/output-startup-information.ts +++ b/code/core/src/core-server/utils/output-startup-information.ts @@ -13,23 +13,39 @@ export function outputStartupInformation(options: { name: string; address: string; networkAddress: string; + allowedHosts?: string[] | true; managerTotalTime?: [number, number]; previewTotalTime?: [number, number]; }) { - const { updateInfo, version, name, address, networkAddress, managerTotalTime, previewTotalTime } = - options; + const { + updateInfo, + version, + name, + address, + networkAddress, + allowedHosts, + managerTotalTime, + previewTotalTime, + } = options; + const otherAllowedHosts = + allowedHosts === true + ? 'all (insecure)' + : allowedHosts?.length + ? allowedHosts.join(', ') + : undefined; const updateMessage = createUpdateMessage(updateInfo, version); const serverMessages = [ - `- Local: ${address}`, - `- On your network: ${networkAddress}`, + `- Local: ${address}`, + `- On your network: ${networkAddress}`, + otherAllowedHosts && `- Other allowed hosts: ${otherAllowedHosts}`, ]; logger.logBox( dedent` Storybook ready! - + ${serverMessages.join('\n')}${updateMessage ? `\n\n${updateMessage}` : ''} `, { From ca177587e6a05149d834aece2ba8bb7ceb199e7f Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 3 Mar 2026 15:08:11 +0100 Subject: [PATCH 54/56] Handle review feedback --- .../core/src/core-server/utils/getHostValidationMiddleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/utils/getHostValidationMiddleware.ts b/code/core/src/core-server/utils/getHostValidationMiddleware.ts index 2a40296b60aa..d918627ff6b6 100644 --- a/code/core/src/core-server/utils/getHostValidationMiddleware.ts +++ b/code/core/src/core-server/utils/getHostValidationMiddleware.ts @@ -24,7 +24,7 @@ export type HostValidationOptions = { * @returns `true` if the host is valid, `false` otherwise. */ export const isValidHost = (host: string | undefined, options: HostValidationOptions): boolean => { - const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; + const allowedHosts = options.allowedHosts ?? DEFAULT_ALLOWED_HOSTS; if (allowedHosts === true) { return true; } @@ -57,7 +57,7 @@ export function getHostValidationMiddleware( ): Middleware { return (req, res, next) => { const host = req.headers.host; - const allowedHosts = options.allowedHosts || DEFAULT_ALLOWED_HOSTS; + const allowedHosts = options.allowedHosts ?? DEFAULT_ALLOWED_HOSTS; if (allowedHosts !== true && !isValidHost(host, options)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Invalid host'); From efbc16efb9bc85451610be1c1153fa84eb547af6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 3 Mar 2026 21:46:48 +0100 Subject: [PATCH 55/56] Fix copilot instructions --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index eed68db653cb..73a7eaad10a9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -33,4 +33,4 @@ jobs: install-code-deps: true - name: Compile - run: yarn nx run-many --targets compile + run: yarn nx run-many --targets compile --no-cloud From 8f6f950d734b5bbe7f748aff81d7b5db6519587b Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:50:39 +0000 Subject: [PATCH 56/56] Write changelog for 10.3.0-alpha.14 [skip ci] --- CHANGELOG.prerelease.md | 10 ++++++++++ code/package.json | 3 ++- docs/versions/next.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 1924a7e280f3..ed6352042df4 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,13 @@ +## 10.3.0-alpha.14 + +- CSF-Factories: Fix ConfigFile parser false warning on `definePreview({...}).type()` export default - [#33885](https://github.com/storybookjs/storybook/pull/33885), thanks @copilot-swe-agent! +- Core: Add host/origin validation to requests and websocket connections - [#33835](https://github.com/storybookjs/storybook/pull/33835), thanks @ghengeveld! +- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates! +- Core: Zoom tool refinements - Hide reset button when value is initial - [#33635](https://github.com/storybookjs/storybook/pull/33635), thanks @superLipbalm! +- Docs: Edit JSON button is now accessible at 320x256 viewport (WCAG 2.1 Reflow test) - [#33707](https://github.com/storybookjs/storybook/pull/33707), thanks @TheSeydiCharyyev! +- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319! +- UI: Allow direct kb/mouse actions on zoom tool button - [#33496](https://github.com/storybookjs/storybook/pull/33496), thanks @Sidnioulz! + ## 10.3.0-alpha.13 - A11y: Add ScrollArea prop focusable for when it has static children - [#33876](https://github.com/storybookjs/storybook/pull/33876), thanks @Sidnioulz! diff --git a/code/package.json b/code/package.json index 1e632e8b94b0..156de43dc5b1 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.3.0-alpha.14" } diff --git a/docs/versions/next.json b/docs/versions/next.json index bab423c17c18..a39b20674836 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.13","info":{"plain":"- A11y: Add ScrollArea prop focusable for when it has static children - [#33876](https://github.com/storybookjs/storybook/pull/33876), thanks @Sidnioulz!\n- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf!\n- Controls: Fix Object contrast issue and tidy up code - [#33923](https://github.com/storybookjs/storybook/pull/33923), thanks @Sidnioulz!\n- HMR: Fix race conditions causing stale play functions to fire on re-rendered stories - [#33930](https://github.com/storybookjs/storybook/pull/33930), thanks @copilot-swe-agent!\n- React: Handle render identifier in manifest snippet generation - [#33940](https://github.com/storybookjs/storybook/pull/33940), thanks @kasperpeulen!\n- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000!"}} \ No newline at end of file +{"version":"10.3.0-alpha.14","info":{"plain":"- CSF-Factories: Fix ConfigFile parser false warning on `definePreview({...}).type()` export default - [#33885](https://github.com/storybookjs/storybook/pull/33885), thanks @copilot-swe-agent!\n- Core: Add host/origin validation to requests and websocket connections - [#33835](https://github.com/storybookjs/storybook/pull/33835), thanks @ghengeveld!\n- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates!\n- Core: Zoom tool refinements - Hide reset button when value is initial - [#33635](https://github.com/storybookjs/storybook/pull/33635), thanks @superLipbalm!\n- Docs: Edit JSON button is now accessible at 320x256 viewport (WCAG 2.1 Reflow test) - [#33707](https://github.com/storybookjs/storybook/pull/33707), thanks @TheSeydiCharyyev!\n- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319!\n- UI: Allow direct kb/mouse actions on zoom tool button - [#33496](https://github.com/storybookjs/storybook/pull/33496), thanks @Sidnioulz!"}} \ No newline at end of file