From ec2587d1a3f249717bf48b3139fdeb33d80cd3b3 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:27:35 +0000 Subject: [PATCH 1/4] Update ./docs/versions/next.json for v10.3.0-alpha.6 --- docs/versions/next.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versions/next.json b/docs/versions/next.json index 5e3e56283303..ab12a3d18ed7 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.5","info":{"plain":"- Builder-Vite: Use relative path for mocker entry in production builds - [#33792](https://github.com/storybookjs/storybook/pull/33792), thanks @DukeDeSouth!\n- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic!\n- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel!\n- Compile: reduce VCPUs for CI check task from 4 to 3 - [#33822](https://github.com/storybookjs/storybook/pull/33822), thanks @valentinpalkovic!\n- Core: Ignore empty files when indexing - [#33782](https://github.com/storybookjs/storybook/pull/33782), thanks @JReinhold!\n- Globals: Repair dynamicTitle: false for user-defined tools - [#33284](https://github.com/storybookjs/storybook/pull/33284), thanks @ia319!\n- Logger: Honor --loglevel for npmlog output - [#33776](https://github.com/storybookjs/storybook/pull/33776), thanks @LouisLau-art!\n- Telemetry: Add Expo metaframework - [#33783](https://github.com/storybookjs/storybook/pull/33783), thanks @copilot-swe-agent!\n- Telemetry: Add init exit event - [#33773](https://github.com/storybookjs/storybook/pull/33773), thanks @valentinpalkovic!\n- Telemetry: Add share events - [#33766](https://github.com/storybookjs/storybook/pull/33766), thanks @ndelangen!\n- Test: Update event creation logic in user-event package - [#33787](https://github.com/storybookjs/storybook/pull/33787), thanks @valentinpalkovic!\n- Viewport: Skip viewport validation before parameters load - [#33794](https://github.com/storybookjs/storybook/pull/33794), thanks @ia319!"}} +{"version":"10.3.0-alpha.6","info":{"plain":"- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic!\n- Addon-Vitest: Support Vitest canaries - [#33833](https://github.com/storybookjs/storybook/pull/33833), thanks @valentinpalkovic!\n- Builder-Vite: Update dependencies react-vite framework - [#33810](https://github.com/storybookjs/storybook/pull/33810), thanks @valentinpalkovic!\n- Next.js: Fix Link component override in appDirectory configuration - [#31251](https://github.com/storybookjs/storybook/pull/31251), thanks @yatishgoel!"}} \ No newline at end of file From afd8372394e6b78d2d713fdc5492691b7d354f3d Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:15:41 +0000 Subject: [PATCH 2/4] Write changelog for 10.2.10 [skip ci] --- CHANGELOG.md | 4 ++++ code/package.json | 3 ++- docs/versions/latest.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e70321271c32..cb65d5279dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 10.2.10 + +- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld! + ## 10.2.9 - Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic! diff --git a/code/package.json b/code/package.json index a9f9025ff273..bcb95c728588 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.10" } diff --git a/docs/versions/latest.json b/docs/versions/latest.json index 8492f9efa12c..8832f143e9e2 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"10.2.9","info":{"plain":"- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic!\n- Builder-Vite: Update dependencies react-vite framework - [#33810](https://github.com/storybookjs/storybook/pull/33810), thanks @valentinpalkovic!\n- Builder-Vite: Use relative path for mocker entry in production builds - [#33792](https://github.com/storybookjs/storybook/pull/33792), thanks @DukeDeSouth!\n- Next.js: Fix Link component override in appDirectory configuration - [#31251](https://github.com/storybookjs/storybook/pull/31251), thanks @yatishgoel!"}} \ No newline at end of file +{"version":"10.2.10","info":{"plain":"- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!"}} \ No newline at end of file From fd275fb2cbffcddb95cbd4842ec58ed188cda073 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Feb 2026 09:12:45 +0100 Subject: [PATCH 3/4] Merge pull request #33820 from storybookjs/harden-websocket-security Core: Require token for websocket connections (cherry picked from commit 5f8dace7ff90d0fb91729de8f848f4ad57b62c7f) --- .../src/preview/iframe-webpack.config.ts | 2 +- .../src/builder-manager/utils/framework.ts | 8 ++- code/core/src/channels/index.ts | 5 +- code/core/src/core-server/dev-server.ts | 5 +- .../src/core-server/presets/common-preset.ts | 10 +++ .../utils/__tests__/server-channel.test.ts | 62 +++++++++++++++++-- .../core-server/utils/get-server-channel.ts | 32 +++++++--- .../utils/getAccessControlMiddleware.ts | 4 -- code/core/src/core-server/utils/index-json.ts | 5 ++ .../utils/validate-websocket-token.ts | 21 +++++++ code/core/src/types/modules/core-common.ts | 2 +- scripts/tasks/compile.ts | 2 +- 12 files changed, 133 insertions(+), 25 deletions(-) create mode 100644 code/core/src/core-server/utils/validate-websocket-token.ts diff --git a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts index ab35c51aece8..7b5644297a8f 100644 --- a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -1,4 +1,4 @@ -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index cff2475a123b..df89e68a6336 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -7,9 +7,9 @@ import { import { type Options, SupportedBuilder } from 'storybook/internal/types'; export const buildFrameworkGlobalsFromOptions = async (options: Options) => { - const globals: Record = {}; + const globals: Record = {}; - const builderConfig = (await options.presets.apply('core')).builder; + const { builder: builderConfig, channelOptions } = await options.presets.apply('core'); const builderName = typeof builderConfig === 'string' ? builderConfig : builderConfig?.name; const builder = Object.values(SupportedBuilder).find((builder) => builderName?.includes(builder)); @@ -18,6 +18,10 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const framework = frameworkPackages[frameworkPackageName]; const renderer = frameworkToRenderer[framework]; + if (options.configType === 'DEVELOPMENT') { + // Manager only needs the token currently, so we don't pass any other channel options. + globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken }; + } globals.STORYBOOK_BUILDER = builder; globals.STORYBOOK_FRAMEWORK = framework; globals.STORYBOOK_RENDERER = renderer; diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index 988b1bdb797c..c6979045e6e4 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -7,7 +7,7 @@ import { PostMessageTransport } from './postmessage'; import type { ChannelTransport, Config } from './types'; import { WebsocketTransport } from './websocket'; -const { CONFIG_TYPE } = global; +const { CHANNEL_OPTIONS, CONFIG_TYPE } = global; export * from './main'; @@ -35,7 +35,8 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C if (CONFIG_TYPE === 'DEVELOPMENT') { const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'; const { hostname, port } = window.location; - const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel`; + const { wsToken } = CHANNEL_OPTIONS || {}; + const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${wsToken}`; transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page })); } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index da9cf0cb6042..e03361cf542c 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 assert from 'assert'; import polka from 'polka'; import invariant from 'tiny-invariant'; @@ -28,9 +29,11 @@ export async function storybookDevServer(options: Options) { const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]); const app = polka({ server }); + assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel'); + const serverChannel = await options.presets.apply( 'experimental_serverChannel', - getServerChannel(server) + getServerChannel(server, core.channelOptions.wsToken) ); const workingDir = process.cwd(); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 422b45c1e2fa..d2b7cfccf54a 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -190,8 +191,13 @@ export const experimental_serverAPI = (extension: Record, opti * ...existing, someConfig })`, just overwriting everything and not merging with the existing * values. */ +const wsToken = randomUUID(); export const core = async (existing: CoreConfig, options: Options): Promise => ({ ...existing, + channelOptions: { + ...(existing?.channelOptions ?? {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + }, disableTelemetry: options.disableTelemetry === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), @@ -250,6 +256,10 @@ export const managerHead = async (_: any, options: Options) => { return ''; }; +export const channelToken = async (value: string | undefined) => { + return value; +}; + export const experimental_serverChannel = async ( channel: Channel, options: OptionsWithRequiredCache 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 e3be39389622..db7b73ba1de0 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 @@ -11,22 +11,24 @@ import { ServerChannelTransport, getServerChannel } from '../get-server-channel' describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; - const result = getServerChannel(server); + const result = getServerChannel(server, '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); + getServerChannel(server, 'test-token-123'); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); describe('ServerChannelTransport', () => { + const mockToken = 'test-token-123'; + it('parses simple JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -36,10 +38,11 @@ describe('ServerChannelTransport', () => { expect(handler).toHaveBeenCalledWith('hello'); }); + it('parses object JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -49,10 +52,11 @@ describe('ServerChannelTransport', () => { expect(handler).toHaveBeenCalledWith({ type: 'hello' }); }); + it('supports telejson cyclical data', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -70,4 +74,52 @@ describe('ServerChannelTransport', () => { } `); }); + + it('rejects connections with invalid token', () => { + 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'); + new ServerChannelTransport(server, mockToken); + + // Simulate upgrade request with wrong token + const request = { + url: '/storybook-server-channel?token=wrong-token', + } as any; + const head = Buffer.from(''); + + 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 valid token', () => { + 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, 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 + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + } as any; + const head = Buffer.from(''); + + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); }); 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 004910cf516a..5ff31bcb296e 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -1,3 +1,5 @@ +import type { IncomingMessage } from 'node:http'; + import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; @@ -5,6 +7,7 @@ import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; +import { isValidToken } from './validate-websocket-token'; type Server = NonNullable[0]>['server']>; @@ -19,14 +22,27 @@ export class ServerChannelTransport { private handler?: ChannelHandler; - constructor(server: Server) { + private token: string; + + constructor(server: Server, token: string) { + this.token = token; this.socket = new WebSocketServer({ noServer: true }); - server.on('upgrade', (request, socket, head) => { - if (request.url === '/storybook-server-channel') { - this.socket.handleUpgrade(request, socket, head, (ws) => { - this.socket.emit('connection', ws, request); - }); + 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); + }); + } } }); this.socket.on('connection', (wss) => { @@ -68,8 +84,8 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server) { - const transports = [new ServerChannelTransport(server)]; +export function getServerChannel(server: Server, token: string) { + const transports = [new ServerChannelTransport(server, token)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/getAccessControlMiddleware.ts b/code/core/src/core-server/utils/getAccessControlMiddleware.ts index 0b5c3428b842..e81c0f18aeef 100644 --- a/code/core/src/core-server/utils/getAccessControlMiddleware.ts +++ b/code/core/src/core-server/utils/getAccessControlMiddleware.ts @@ -2,13 +2,9 @@ import type { Middleware } from '../../types'; export function getAccessControlMiddleware(crossOriginIsolated: boolean): Middleware { return (req, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); // These headers are required to enable SharedArrayBuffer // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer if (crossOriginIsolated) { - // These headers are required to enable SharedArrayBuffer - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); } diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 08e1b5047957..5c098805db14 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -58,6 +58,11 @@ export function registerIndexJsonRoute({ try { const index = await (await storyIndexGeneratorPromise).getIndex(); res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept' + ); res.end(JSON.stringify(index)); } catch (err) { res.statusCode = 500; diff --git a/code/core/src/core-server/utils/validate-websocket-token.ts b/code/core/src/core-server/utils/validate-websocket-token.ts new file mode 100644 index 000000000000..fc5c29b802b8 --- /dev/null +++ b/code/core/src/core-server/utils/validate-websocket-token.ts @@ -0,0 +1,21 @@ +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 { + // TODO: Remove any types as soon as @types/node is updated + return a.length === b.length && timingSafeEqual(a as any, b as any); + } 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..2f9330515d27 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -32,7 +32,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial; + channelOptions?: Partial & { wsToken?: string }; /** Disables the generation of project.json, a file containing Storybook metadata */ disableProjectJson?: boolean; /** diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index 4ab621b014a2..eaa4ef2a699f 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -7,7 +7,7 @@ import { exec } from '../utils/exec'; import { maxConcurrentTasks } from '../utils/maxConcurrentTasks'; // The amount of VCPUs for the check task on CI is 4 (large resource) -const amountOfVCPUs = 4; +const amountOfVCPUs = 2; const parallel = `--parallel=${process.env.CI ? amountOfVCPUs - 1 : maxConcurrentTasks}`; From a4605ccb81eb0740c904dceb70772cdcf30a50d6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Feb 2026 09:45:27 +0100 Subject: [PATCH 4/4] Remove code path for link mock in Next.js package.json --- code/frameworks/nextjs/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json index 53824380995a..96e4b574e00d 100644 --- a/code/frameworks/nextjs/package.json +++ b/code/frameworks/nextjs/package.json @@ -46,7 +46,6 @@ "./images/next-legacy-image": "./dist/images/next-legacy-image.js", "./link.mock": { "types": "./dist/export-mocks/link/index.d.ts", - "code": "./src/export-mocks/link/index.tsx", "default": "./dist/export-mocks/link/index.js" }, "./navigation.mock": {