VYPR
High severityNVD Advisory· Published Feb 25, 2026· Updated Feb 26, 2026

Storybook Dev Server Vulnerable to WebSocket Hijacking

CVE-2026-27148

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.

PackageAffected versionsPatched versions
storybooknpm
>= 8.1.0, < 8.6.178.6.17
storybooknpm
>= 8.7.0-alpha.0, < 9.1.199.1.19
storybooknpm
>= 10.0.0-beta.0, < 10.2.1010.2.10

Affected products

2
  • Range: prior to 7.6.23, 8.6.17, 9.1.19, 10.2.10
  • storybookjs/storybookv5
    Range: < 7.6.23

Patches

4
54689a8add18

Merge pull request #33863 from storybookjs/version-patch-from-10.2.9

https://github.com/storybookjs/storybookValentin PalkovicFeb 18, 2026via ghsa
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}`;
     
    
0affdf928bd6

Merge pull request #33860 from storybookjs/hotfix/v9.1.19

https://github.com/storybookjs/storybookValentin PalkovicFeb 18, 2026via ghsa
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"
     }
    
b8cfa77c7394

Merge pull request #33857 from storybookjs/yann/v8-ws-hotfix

https://github.com/storybookjs/storybookValentin PalkovicFeb 18, 2026via ghsa
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;
       /**
    
d34085f39c64

Merge pull request #33859 from storybookjs/hotfix/v7.6.23

https://github.com/storybookjs/storybookValentin PalkovicFeb 18, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.