Storybook Dev Server Vulnerable to WebSocket Hijacking
Description
Storybook is a frontend workshop for building user interface components and pages in isolation. Prior to versions 7.6.23, 8.6.17, 9.1.19, and 10.2.10, the WebSocket functionality in Storybook's dev server, used to create and update stories, is vulnerable to WebSocket hijacking. This vulnerability only affects the Storybook dev server; production builds are not impacted. Exploitation requires a developer to visit a malicious website while their local Storybook dev server is running. Because the WebSocket connection does not validate the origin of incoming connections, a malicious site can silently send WebSocket messages to the local instance without any further user interaction. If the Storybook dev server is intentionally exposed publicly (e.g. for design reviews or stakeholder demos) the risk is higher, as no malicious site visit is required. Any unauthenticated attacker can send WebSocket messages to it directly. The vulnerability affects the WebSocket message handlers for creating and saving stories. Both are vulnerable to injection via unsanitized input in the componentFilePath field, which can be exploited to achieve persistent XSS or Remote Code Execution (RCE). Versions 7.6.23, 8.6.17, 9.1.19, and 10.2.10 contain a fix for the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Storybook dev server WebSocket hijacking allows unauthenticated attackers to achieve persistent XSS or RCE via unsanitized componentFilePath input.
Vulnerability
Description
Storybook's development server uses WebSocket connections to create and update stories, but prior to versions 7.6.23, 8.6.17, 9.1.19, and 10.2.10, these connections did not validate the origin of incoming requests. This lack of origin checking enables WebSocket hijacking, where a malicious website can silently send crafted messages to a developer's local Storybook instance if the developer visits that site while the dev server is running [1].
Exploitation
Prerequisites
Exploitation requires either a developer to visit a malicious website while their local Storybook dev server is active, or the dev server to be intentionally exposed publicly (e.g., for design reviews). In the latter case, no user interaction is needed—any unauthenticated attacker can directly send WebSocket messages to the exposed server [1].
Impact
The vulnerability affects WebSocket message handlers for creating and saving stories. The componentFilePath field is not sanitized, allowing injection attacks. An attacker can exploit this to achieve persistent cross-site scripting (XSS) or remote code execution (RCE) on the developer's machine [1].
Mitigation
Storybook has released patched versions (7.6.23, 8.6.17, 9.1.19, 10.2.10) that add a token-based authentication mechanism to the WebSocket channel, as seen in the fix commits [3][4]. Users should update immediately. Production builds are not affected.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
storybooknpm | >= 8.1.0, < 8.6.17 | 8.6.17 |
storybooknpm | >= 8.7.0-alpha.0, < 9.1.19 | 9.1.19 |
storybooknpm | >= 10.0.0-beta.0, < 10.2.10 | 10.2.10 |
Affected products
2- storybookjs/storybookv5Range: < 7.6.23
Patches
454689a8add18Merge pull request #33863 from storybookjs/version-patch-from-10.2.9
17 files changed · +141 −29
CHANGELOG.md+4 −0 modified@@ -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!
code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts+1 −1 modified@@ -1,4 +1,4 @@ -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import {
code/core/src/builder-manager/utils/framework.ts+6 −2 modified@@ -7,9 +7,9 @@ import { import { type Options, SupportedBuilder } from 'storybook/internal/types'; export const buildFrameworkGlobalsFromOptions = async (options: Options) => { - const globals: Record<string, string | undefined> = {}; + const globals: Record<string, any> = {}; - 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;
code/core/src/channels/index.ts+3 −2 modified@@ -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 })); }
code/core/src/core-server/dev-server.ts+4 −1 modified@@ -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();
code/core/src/core-server/presets/common-preset.ts+10 −0 modified@@ -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<string, Function>, 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<CoreConfig> => ({ ...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
code/core/src/core-server/utils/getAccessControlMiddleware.ts+0 −4 modified@@ -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'); }
code/core/src/core-server/utils/get-server-channel.ts+24 −8 modified@@ -1,10 +1,13 @@ +import type { IncomingMessage } from 'node:http'; + import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; 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<NonNullable<ConstructorParameters<typeof WebSocketServer>[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 });
code/core/src/core-server/utils/index-json.ts+5 −0 modified@@ -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;
code/core/src/core-server/utils/__tests__/server-channel.test.ts+57 −5 modified@@ -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(); + }); });
code/core/src/core-server/utils/validate-websocket-token.ts+21 −0 added@@ -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; + } +}
code/core/src/types/modules/core-common.ts+1 −1 modified@@ -32,7 +32,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial<TelejsonOptions>; + channelOptions?: Partial<TelejsonOptions> & { wsToken?: string }; /** Disables the generation of project.json, a file containing Storybook metadata */ disableProjectJson?: boolean; /**
code/frameworks/nextjs/package.json+0 −1 modified@@ -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": {
code/package.json+2 −1 modified@@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.10" }
docs/versions/latest.json+1 −1 modified@@ -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
docs/versions/next.json+1 −1 modified@@ -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
scripts/tasks/compile.ts+1 −1 modified@@ -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}`;
0affdf928bd6Merge pull request #33860 from storybookjs/hotfix/v9.1.19
12 files changed · +155 −24
CHANGELOG.md+4 −0 modified@@ -1,3 +1,7 @@ +## 9.1.19 + +- Harden websocket connection + ## 9.1.18 - No-op release. No changes.
code/core/src/builder-manager/utils/framework.ts+6 −1 modified@@ -33,11 +33,16 @@ export const pluckThirdPartyPackageFromPath = (packagePath: string) => export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const globals: Record<string, any> = {}; - const { builder } = await options.presets.apply('core'); + const { builder, channelOptions } = await options.presets.apply('core'); const frameworkName = await getFrameworkName(options); const rendererName = await extractProperRendererNameFromFramework(frameworkName); + 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 }; + } + if (rendererName) { globals.STORYBOOK_RENDERER = (await extractProperRendererNameFromFramework(frameworkName)) ?? undefined;
code/core/src/channels/index.ts+3 −2 modified@@ -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 })); }
code/core/src/core-server/dev-server.ts+4 −1 modified@@ -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'; @@ -26,9 +27,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) ); let indexError: Error | undefined;
code/core/src/core-server/presets/common-preset.ts+10 −1 modified@@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; @@ -181,7 +182,7 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti } return { ...extension, removeAddon }; }; - +const wsToken = randomUUID(); /** * If for some reason this config is not applied, the reason is that likely there is an addon that * does `export core = () => ({ someConfig })`, instead of `export core = (existing) => ({ @@ -191,6 +192,10 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti export const core = async (existing: CoreConfig, options: Options): Promise<CoreConfig> => ({ ...existing, disableTelemetry: options.disableTelemetry === true, + channelOptions: { + ...(existing?.channelOptions ?? {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + }, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), }); @@ -247,6 +252,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
code/core/src/core-server/utils/getAccessControlMiddleware.ts+0 −4 modified@@ -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'); }
code/core/src/core-server/utils/get-server-channel.ts+24 −8 modified@@ -1,10 +1,13 @@ +import type { IncomingMessage } from 'node:http'; + import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; 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<NonNullable<ConstructorParameters<typeof WebSocketServer>[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 });
code/core/src/core-server/utils/stories-json.ts+5 −0 modified@@ -62,6 +62,11 @@ export function useStoriesJson({ const generator = await initializedStoryIndexGenerator; const index = await generator.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;
code/core/src/core-server/utils/__tests__/server-channel.test.ts+76 −5 modified@@ -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,71 @@ describe('ServerChannelTransport', () => { } `); }); + + it('skips telejson classes and functions in data', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter(); + const transport = new ServerChannelTransport(server, mockToken); + const handler = vi.fn(); + transport.setHandler(handler); + + // @ts-expect-error (an internal API) + transport.socket.emit('connection', socket); + + const input = { a() {}, b: class {}, c: true, d: 3 }; + socket.emit('message', stringify(input)); + + console.log(handler.mock.calls); + + expect(handler.mock.calls[0][0].a).toBeUndefined(); + expect(handler.mock.calls[0][0].b).toBeUndefined(); + }); + + 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(); + }); });
code/core/src/core-server/utils/validate-websocket-token.ts+20 −0 added@@ -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(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; + } +}
code/core/src/types/modules/core-common.ts+1 −1 modified@@ -26,7 +26,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial<TelejsonOptions>; + channelOptions?: Partial<TelejsonOptions> & { wsToken?: string }; /** Disables the generation of project.json, a file containing Storybook metadata */ disableProjectJson?: boolean; /**
code/package.json+2 −1 modified@@ -283,5 +283,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "9.1.19" }
b8cfa77c7394Merge pull request #33857 from storybookjs/yann/v8-ws-hotfix
10 files changed · +131 −24
code/core/src/builder-manager/utils/framework.ts+6 −1 modified@@ -30,11 +30,16 @@ export const pluckThirdPartyPackageFromPath = (packagePath: string) => export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const globals: Record<string, any> = {}; - const { builder } = await options.presets.apply('core'); + const { builder, channelOptions } = await options.presets.apply('core'); const frameworkName = await getFrameworkName(options); const rendererName = await extractProperRendererNameFromFramework(frameworkName); + 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 }; + } + if (rendererName) { globals.STORYBOOK_RENDERER = (await extractProperRendererNameFromFramework(frameworkName)) ?? undefined;
code/core/src/channels/index.ts+3 −2 modified@@ -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 })); }
code/core/src/core-server/dev-server.ts+4 −1 modified@@ -5,6 +5,7 @@ import { logger } from '@storybook/core/node-logger'; import { MissingBuilderError } from '@storybook/core/server-errors'; import compression from '@polka/compression'; +import assert from 'assert'; import polka from 'polka'; import invariant from 'tiny-invariant'; @@ -25,9 +26,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) ); let indexError: Error | undefined;
code/core/src/core-server/presets/common-preset.ts+10 −1 modified@@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; @@ -206,7 +207,7 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti } return { ...extension, removeAddon }; }; - +const wsToken = randomUUID(); /** * If for some reason this config is not applied, the reason is that likely there is an addon that * does `export core = () => ({ someConfig })`, instead of `export core = (existing) => ({ @@ -215,6 +216,10 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti */ export const core = async (existing: CoreConfig, options: Options): Promise<CoreConfig> => ({ ...existing, + channelOptions: { + ...(existing?.channelOptions ?? {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + }, disableTelemetry: options.disableTelemetry === true || options.test === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), @@ -273,6 +278,10 @@ export const managerHead = async (_: any, options: Options) => { return ''; }; +export const channelToken = async (value: string | undefined) => { + return value; +}; + // eslint-disable-next-line @typescript-eslint/naming-convention export const experimental_serverChannel = async ( channel: Channel,
code/core/src/core-server/utils/getAccessControlMiddleware.ts+0 −4 modified@@ -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'); }
code/core/src/core-server/utils/get-server-channel.ts+24 −8 modified@@ -1,10 +1,13 @@ +import type { IncomingMessage } from 'node:http'; + import type { ChannelHandler } from '@storybook/core/channels'; import { Channel, HEARTBEAT_INTERVAL } from '@storybook/core/channels'; 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<NonNullable<ConstructorParameters<typeof WebSocketServer>[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) => { @@ -71,8 +87,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 });
code/core/src/core-server/utils/stories-json.ts+5 −0 modified@@ -63,6 +63,11 @@ export function useStoriesJson({ const generator = await initializedStoryIndexGenerator; const index = await generator.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;
code/core/src/core-server/utils/__tests__/server-channel.test.ts+58 −6 modified@@ -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); @@ -73,7 +77,7 @@ describe('ServerChannelTransport', () => { it('skips telejson classes and functions in 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); @@ -86,4 +90,52 @@ describe('ServerChannelTransport', () => { expect(handler.mock.calls[0][0].a).toEqual(expect.any(String)); expect(handler.mock.calls[0][0].b).toEqual(expect.any(String)); }); + + 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(); + }); });
code/core/src/core-server/utils/validate-websocket-token.ts+20 −0 added@@ -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(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; + } +}
code/core/src/types/modules/core-common.ts+1 −1 modified@@ -26,7 +26,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial<TelejsonOptions>; + channelOptions?: Partial<TelejsonOptions> & { wsToken?: string }; /** Disables the generation of project.json, a file containing Storybook metadata */ disableProjectJson?: boolean; /**
d34085f39c64Merge pull request #33859 from storybookjs/hotfix/v7.6.23
13 files changed · +156 −37
CHANGELOG.md+4 −0 modified@@ -1,3 +1,7 @@ +## 7.6.23 + +- Harden websocke connection + ## 7.6.22 - No-op release. No changes.
code/builders/builder-manager/src/utils/template.ts+19 −11 modified@@ -34,25 +34,33 @@ export const renderHTML = async ( refs: Promise<Record<string, Ref>>, logLevel: Promise<string>, docsOptions: Promise<DocsOptions>, - { versionCheck, previewUrl, configType }: Options + { versionCheck, previewUrl, configType, presets }: Options ) => { const titleRef = await title; const templateRef = await template; + const globals: Record<string, any> = { + FEATURES: JSON.stringify(await features, null, 2), + REFS: JSON.stringify(await refs, null, 2), + LOGLEVEL: JSON.stringify(await logLevel, null, 2), + DOCS_OPTIONS: JSON.stringify(await docsOptions, null, 2), + CONFIG_TYPE: JSON.stringify(await configType, null, 2), + // These two need to be double stringified because the UI expects a string + VERSIONCHECK: JSON.stringify(JSON.stringify(versionCheck), null, 2), + PREVIEW_URL: JSON.stringify(previewUrl, null, 2), // global preview URL + }; + + if (configType === 'DEVELOPMENT') { + const coreOptions = await presets.apply('core'); + // Manager only needs the token currently, so we don't pass any other channel options. + globals.CHANNEL_OPTIONS = JSON.stringify({ wsToken: coreOptions?.channelOptions?.wsToken }, null, 2); + } + return render(templateRef, { title: titleRef ? `${titleRef} - Storybook` : 'Storybook', files: { js: jsFiles, css: cssFiles }, favicon: await favicon, - globals: { - FEATURES: JSON.stringify(await features, null, 2), - REFS: JSON.stringify(await refs, null, 2), - LOGLEVEL: JSON.stringify(await logLevel, null, 2), - DOCS_OPTIONS: JSON.stringify(await docsOptions, null, 2), - CONFIG_TYPE: JSON.stringify(await configType, null, 2), - // These two need to be double stringified because the UI expects a string - VERSIONCHECK: JSON.stringify(JSON.stringify(versionCheck), null, 2), - PREVIEW_URL: JSON.stringify(previewUrl, null, 2), // global preview URL - }, + globals, head: (await customHead) || '', }); };
code/lib/channels/src/index.ts+3 −2 modified@@ -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'; @@ -33,7 +33,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: () => {} })); }
code/lib/core-server/src/dev-server.ts+5 −1 modified@@ -1,5 +1,7 @@ import express from 'express'; import compression from 'compression'; + +import assert from 'assert'; import invariant from 'tiny-invariant'; import type { CoreConfig, Options, StorybookConfig } from '@storybook/types'; @@ -33,9 +35,11 @@ export async function storybookDevServer(options: Options) { options.presets.apply<CoreConfig>('core'), ]); + 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) ); if (features?.storyStoreV7 === false) {
code/lib/core-server/src/presets/common-preset.ts+11 −0 modified@@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import fs, { pathExists, readFile } from 'fs-extra'; import { deprecate, logger } from '@storybook/node-logger'; import { telemetry } from '@storybook/telemetry'; @@ -160,6 +161,8 @@ const optionalEnvToBoolean = (input: string | undefined): boolean | undefined => return undefined; }; +const wsToken = randomUUID(); + /** * If for some reason this config is not applied, the reason is that * likely there is an addon that does `export core = () => ({ someConfig })`, @@ -168,6 +171,10 @@ const optionalEnvToBoolean = (input: string | undefined): boolean | undefined => */ export const core = async (existing: CoreConfig, options: Options): Promise<CoreConfig> => ({ ...existing, + channelOptions: { + ...(existing?.channelOptions ?? {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + }, disableTelemetry: options.disableTelemetry === true || options.test === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), @@ -260,6 +267,10 @@ type WhatsNewResponse = { type OptionsWithRequiredCache = Exclude<Options, 'cache'> & Required<Pick<Options, 'cache'>>; +export const channelToken = async (value: string | undefined) => { + return value; +}; + // eslint-disable-next-line @typescript-eslint/naming-convention export const experimental_serverChannel = async ( channel: Channel,
code/lib/core-server/src/utils/getAccessControlMiddleware.ts+0 −4 modified@@ -2,10 +2,6 @@ import type { RequestHandler } from 'express'; export function getAccessControlMiddleware(crossOriginIsolated: boolean): RequestHandler { return (req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('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
code/lib/core-server/src/utils/get-server-channel.ts+24 −8 modified@@ -2,6 +2,9 @@ import WebSocket, { WebSocketServer } from 'ws'; import { isJSON, parse, stringify } from 'telejson'; import type { ChannelHandler } from '@storybook/channels'; import { Channel } from '@storybook/channels'; +import type { IncomingMessage } from 'node:http'; + +import { isValidToken } from './validate-websocket-token'; type Server = NonNullable<NonNullable<ConstructorParameters<typeof WebSocketServer>[0]>['server']>; @@ -14,14 +17,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) => { @@ -49,8 +65,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)]; return new Channel({ transports, async: true }); }
code/lib/core-server/src/utils/stories-json.ts+4 −0 modified@@ -48,6 +48,8 @@ export function useStoriesJson({ const generator = await initializedStoryIndexGenerator; const index = await generator.getIndex(); res.header('Content-Type', 'application/json'); + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); res.send(JSON.stringify(index)); } catch (err) { res.status(500); @@ -60,6 +62,8 @@ export function useStoriesJson({ const generator = await initializedStoryIndexGenerator; const index = convertToIndexV3(await generator.getIndex()); res.header('Content-Type', 'application/json'); + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); res.send(JSON.stringify(index)); } catch (err) { res.status(500);
code/lib/core-server/src/utils/__tests__/server-channel.test.ts+62 −8 modified@@ -8,22 +8,24 @@ import { getServerChannel, ServerChannelTransport } from '../get-server-channel' describe('getServerChannel', () => { test('should return a channel', () => { const server = { on: jest.fn() } as any as Server; - const result = getServerChannel(server); + const result = getServerChannel(server, 'test-token-123'); expect(result).toBeInstanceOf(Channel); }); test('should attach to the http server', () => { const server = { on: jest.fn() } as any as Server; - getServerChannel(server); + getServerChannel(server, 'test-token-123'); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); describe('ServerChannelTransport', () => { - test('parses simple JSON', () => { + 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 = jest.fn(); transport.setHandler(handler); @@ -33,10 +35,11 @@ describe('ServerChannelTransport', () => { expect(handler).toHaveBeenCalledWith('hello'); }); - test('parses object JSON', () => { + + 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 = jest.fn(); transport.setHandler(handler); @@ -49,7 +52,7 @@ describe('ServerChannelTransport', () => { test('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 = jest.fn(); transport.setHandler(handler); @@ -70,7 +73,7 @@ describe('ServerChannelTransport', () => { test('skips telejson classes and functions in 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 = jest.fn(); transport.setHandler(handler); @@ -83,4 +86,55 @@ describe('ServerChannelTransport', () => { expect(handler.mock.calls[0][0].a).toEqual(expect.any(String)); expect(handler.mock.calls[0][0].b).toEqual(expect.any(String)); }); + + it('rejects connections with invalid token', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = jest.fn(); + socket.destroy = jest.fn(); + const destroySpy = jest.spyOn(socket, 'destroy'); + const transport = new ServerChannelTransport(server, mockToken); + + const handler = jest.fn(); + transport.setHandler(handler); + + // 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 = jest.fn(); + socket.destroy = jest.fn(); + const destroySpy = jest.spyOn(socket, 'destroy'); + const handleUpgradeSpy = jest.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(); + }); });
code/lib/core-server/src/utils/validate-websocket-token.ts+20 −0 added@@ -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(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; + } +}
code/lib/types/src/modules/core-common.ts+1 −1 modified@@ -29,7 +29,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial<TelejsonOptions>; + channelOptions?: Partial<TelejsonOptions> & { wsToken?: string }; /** * Disables the generation of project.json, a file containing Storybook metadata */
code/package.json+2 −1 modified@@ -329,5 +329,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "7.6.23" }
.github/workflows/publish.md+1 −1 modified@@ -7,7 +7,7 @@ 3. `git pull` 4. `git checkout -b hotfix/v<next-patch-release-version>` 5. Apply necessary hotfixes -6. `yarn release:version --deferred --release-type patch --verbose && git add . && git commit -m "Bump deferred version"` +6. `cd scripts && yarn release:version --deferred --release-type patch --verbose && cd .. && git add . && git commit -m "Bump deferred version"` 7. Trigger canary release via dispatching the workflow for `publish-canary` 8. Test the canary release 9. Merge `hotfix/v<next-patch-release-version>` into `v7`
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-mjf5-7g4m-gx5wghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27148ghsaADVISORY
- github.com/storybookjs/storybook/commit/0affdf928bd6fafbadfb1dfe22ce6104805e10e8ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/commit/54689a8add18ea75d628c540f4bc677592a1e685ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/commit/b8cfa77c73940c140acdcd8a06ab1ea913c44761ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/commit/d34085f39c647f5c23c3a3b2d197c18602fcf876ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/releases/tag/v10.2.10ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/releases/tag/v7.6.23ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/releases/tag/v8.6.17ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/releases/tag/v9.1.19ghsax_refsource_MISCWEB
- github.com/storybookjs/storybook/security/advisories/GHSA-mjf5-7g4m-gx5wghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.