From a4436cc1401cbbc19f0a964386271572eae786d2 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 10 Feb 2026 16:21:55 +0100 Subject: [PATCH 1/8] Refactor channel options handling and implement token validation for WebSocket connections - Updated `iframe-webpack.config.ts` to remove unused imports. - Modified `framework.ts` to change the type of `globals` and include `channelOptions` in the applied presets. - Enhanced `index.ts` to extract `CHANNEL_OPTIONS` from global variables and append the token to the WebSocket channel URL. - Adjusted `dev-server.ts` to pass the token to the server channel. - Introduced a new utility `validate-websocket-token.ts` for token validation. - Updated `get-server-channel.ts` to accept a token parameter and validate incoming WebSocket connections. - Added tests in `server-channel.test.ts` to ensure proper handling of token validation for WebSocket connections. This change improves security by ensuring only authorized connections are accepted. --- .../src/preview/iframe-webpack.config.ts | 2 +- .../src/builder-manager/utils/framework.ts | 6 +- code/core/src/channels/index.ts | 5 +- code/core/src/core-server/dev-server.ts | 4 +- .../src/core-server/presets/common-preset.ts | 7 ++ .../utils/__tests__/server-channel.test.ts | 87 +++++++++++++++++-- .../core-server/utils/get-server-channel.ts | 32 +++++-- .../utils/getAccessControlMiddleware.ts | 4 - code/core/src/core-server/utils/index-json.ts | 5 ++ .../utils/validate-websocket-token.ts | 20 +++++ 10 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 code/core/src/core-server/utils/validate-websocket-token.ts diff --git a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts index ab35c51aece8..7b5644297a8f 100644 --- a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -1,4 +1,4 @@ -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index cff2475a123b..5d064f8e18c7 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -7,9 +7,9 @@ import { import { type Options, SupportedBuilder } from 'storybook/internal/types'; export const buildFrameworkGlobalsFromOptions = async (options: Options) => { - const globals: Record = {}; + const globals: Record = {}; - const builderConfig = (await options.presets.apply('core')).builder; + const { builder: builderConfig, channelOptions } = await options.presets.apply('core'); const builderName = typeof builderConfig === 'string' ? builderConfig : builderConfig?.name; const builder = Object.values(SupportedBuilder).find((builder) => builderName?.includes(builder)); @@ -18,6 +18,8 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const framework = frameworkPackages[frameworkPackageName]; const renderer = frameworkToRenderer[framework]; + // Manager only needs the token currently, so we don't pass any other channel options. + globals.CHANNEL_OPTIONS = { token: channelOptions.token }; globals.STORYBOOK_BUILDER = builder; globals.STORYBOOK_FRAMEWORK = framework; globals.STORYBOOK_RENDERER = renderer; diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index 988b1bdb797c..05c715f3f353 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -7,7 +7,7 @@ import { PostMessageTransport } from './postmessage'; import type { ChannelTransport, Config } from './types'; import { WebsocketTransport } from './websocket'; -const { CONFIG_TYPE } = global; +const { CHANNEL_OPTIONS, CONFIG_TYPE } = global; export * from './main'; @@ -35,7 +35,8 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C if (CONFIG_TYPE === 'DEVELOPMENT') { const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'; const { hostname, port } = window.location; - const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel`; + const { token } = CHANNEL_OPTIONS || {}; + const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${token}`; transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page })); } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 1280a698bb8a..8634e8a898d3 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -30,7 +30,7 @@ export async function storybookDevServer(options: Options) { const serverChannel = await options.presets.apply( 'experimental_serverChannel', - getServerChannel(server) + getServerChannel(server, core?.channelOptions?.token) ); const workingDir = process.cwd(); @@ -52,8 +52,6 @@ export async function storybookDevServer(options: Options) { options.extendServer(server); } - // CORS middleware must be registered BEFORE route handlers to ensure all routes - // (including /index.json) receive proper CORS headers for Storybook Composition app.use(getAccessControlMiddleware(core?.crossOriginIsolated ?? false)); app.use(getCachingMiddleware()); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 381b6e8e064c..685174ed5b77 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -190,8 +191,10 @@ export const experimental_serverAPI = (extension: Record, opti * ...existing, someConfig })`, just overwriting everything and not merging with the existing * values. */ +const token = randomUUID(); export const core = async (existing: CoreConfig, options: Options): Promise => ({ ...existing, + channelOptions: { ...existing.channelOptions, token }, disableTelemetry: options.disableTelemetry === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), @@ -257,6 +260,10 @@ export const managerHead = async (_: any, options: Options) => { return ''; }; +export const channelToken = async (value: string | undefined) => { + return value; +}; + export const experimental_serverChannel = async ( channel: Channel, options: OptionsWithRequiredCache diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index e3be39389622..fe17cd1f6185 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -11,22 +11,24 @@ import { ServerChannelTransport, getServerChannel } from '../get-server-channel' describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; - const result = getServerChannel(server); + const result = getServerChannel(server, 'test-token-123'); expect(result).toBeInstanceOf(Channel); }); it('should attach to the http server', () => { const server = { on: vi.fn() } as any as Server; - getServerChannel(server); + getServerChannel(server, 'test-token-123'); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); describe('ServerChannelTransport', () => { + const mockToken = 'test-token-123'; + it('parses simple JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -36,10 +38,11 @@ describe('ServerChannelTransport', () => { expect(handler).toHaveBeenCalledWith('hello'); }); + it('parses object JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -49,10 +52,11 @@ describe('ServerChannelTransport', () => { expect(handler).toHaveBeenCalledWith({ type: 'hello' }); }); + it('supports telejson cyclical data', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server); + const transport = new ServerChannelTransport(server, mockToken); const handler = vi.fn(); transport.setHandler(handler); @@ -70,4 +74,77 @@ describe('ServerChannelTransport', () => { } `); }); + + it('rejects connections without 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 transport = new ServerChannelTransport(server, mockToken); + + // Simulate upgrade request without token + const request = { + url: '/storybook-server-channel', + } as any; + const head = Buffer.from(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).toHaveBeenCalledWith( + 'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n' + ); + expect(destroySpy).toHaveBeenCalled(); + }); + + it('rejects connections 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'); + const transport = 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(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).toHaveBeenCalledWith( + 'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n' + ); + expect(destroySpy).toHaveBeenCalled(); + }); + + it('accepts connections with 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(''); + + // @ts-expect-error (accessing private method via upgrade handler) + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); }); diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 004910cf516a..5ff31bcb296e 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -1,3 +1,5 @@ +import type { IncomingMessage } from 'node:http'; + import type { ChannelHandler } from 'storybook/internal/channels'; import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; @@ -5,6 +7,7 @@ import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; import { UniversalStore } from '../../shared/universal-store'; +import { isValidToken } from './validate-websocket-token'; type Server = NonNullable[0]>['server']>; @@ -19,14 +22,27 @@ export class ServerChannelTransport { private handler?: ChannelHandler; - constructor(server: Server) { + private token: string; + + constructor(server: Server, token: string) { + this.token = token; this.socket = new WebSocketServer({ noServer: true }); - server.on('upgrade', (request, socket, head) => { - if (request.url === '/storybook-server-channel') { - this.socket.handleUpgrade(request, socket, head, (ws) => { - this.socket.emit('connection', ws, request); - }); + server.on('upgrade', (request: IncomingMessage, socket, head) => { + if (request.url) { + const url = new URL(request.url, 'http://localhost'); + if (url.pathname === '/storybook-server-channel') { + const requestToken = url.searchParams.get('token'); + if (!isValidToken(requestToken, this.token)) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + this.socket.handleUpgrade(request, socket, head, (ws) => { + this.socket.emit('connection', ws, request); + }); + } } }); this.socket.on('connection', (wss) => { @@ -68,8 +84,8 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server) { - const transports = [new ServerChannelTransport(server)]; +export function getServerChannel(server: Server, token: string) { + const transports = [new ServerChannelTransport(server, token)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/getAccessControlMiddleware.ts b/code/core/src/core-server/utils/getAccessControlMiddleware.ts index 0b5c3428b842..e81c0f18aeef 100644 --- a/code/core/src/core-server/utils/getAccessControlMiddleware.ts +++ b/code/core/src/core-server/utils/getAccessControlMiddleware.ts @@ -2,13 +2,9 @@ import type { Middleware } from '../../types'; export function getAccessControlMiddleware(crossOriginIsolated: boolean): Middleware { return (req, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); // These headers are required to enable SharedArrayBuffer // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer if (crossOriginIsolated) { - // These headers are required to enable SharedArrayBuffer - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); } diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 08e1b5047957..5c098805db14 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -58,6 +58,11 @@ export function registerIndexJsonRoute({ try { const index = await (await storyIndexGeneratorPromise).getIndex(); res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept' + ); res.end(JSON.stringify(index)); } catch (err) { res.statusCode = 500; diff --git a/code/core/src/core-server/utils/validate-websocket-token.ts b/code/core/src/core-server/utils/validate-websocket-token.ts new file mode 100644 index 000000000000..ec4ff5796cc1 --- /dev/null +++ b/code/core/src/core-server/utils/validate-websocket-token.ts @@ -0,0 +1,20 @@ +import { timingSafeEqual } from 'node:crypto'; + +/** + * Validates a secret token using constant-time comparison to prevent timing attacks. + * + * @returns `true` if tokens match, `false` otherwise + */ +export function isValidToken(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; + } +} From 58ed3dfd08042b433aef09c1e6f298b35ce4056c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 10 Feb 2026 16:39:25 +0100 Subject: [PATCH 2/8] Rename 'token' to 'wsToken' for clarity --- code/core/src/builder-manager/utils/framework.ts | 2 +- code/core/src/channels/index.ts | 4 ++-- code/core/src/core-server/dev-server.ts | 2 +- code/core/src/core-server/presets/common-preset.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index 5d064f8e18c7..7bf7309f12ec 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -19,7 +19,7 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const renderer = frameworkToRenderer[framework]; // Manager only needs the token currently, so we don't pass any other channel options. - globals.CHANNEL_OPTIONS = { token: channelOptions.token }; + globals.CHANNEL_OPTIONS = { wsToken: channelOptions.wsToken }; globals.STORYBOOK_BUILDER = builder; globals.STORYBOOK_FRAMEWORK = framework; globals.STORYBOOK_RENDERER = renderer; diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index 05c715f3f353..c6979045e6e4 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -35,8 +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 { token } = CHANNEL_OPTIONS || {}; - const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${token}`; + const { wsToken } = CHANNEL_OPTIONS || {}; + const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${wsToken}`; transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page })); } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 8634e8a898d3..031d2d4d4768 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -30,7 +30,7 @@ export async function storybookDevServer(options: Options) { const serverChannel = await options.presets.apply( 'experimental_serverChannel', - getServerChannel(server, core?.channelOptions?.token) + getServerChannel(server, core?.channelOptions?.wsToken) ); const workingDir = process.cwd(); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 685174ed5b77..10eadc1b4e03 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -191,10 +191,10 @@ export const experimental_serverAPI = (extension: Record, opti * ...existing, someConfig })`, just overwriting everything and not merging with the existing * values. */ -const token = randomUUID(); +const wsToken = randomUUID(); export const core = async (existing: CoreConfig, options: Options): Promise => ({ ...existing, - channelOptions: { ...existing.channelOptions, token }, + channelOptions: { ...(existing?.channelOptions ?? {}), wsToken }, disableTelemetry: options.disableTelemetry === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), From d99c7b94d74efc55e261181eb7a241a007b9697c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 10:18:39 +0100 Subject: [PATCH 3/8] Enhance websocket security by requiring wsToken in channelOptions and updating type definition for CoreConfig. --- code/core/src/core-server/dev-server.ts | 5 ++++- code/core/src/types/modules/core-common.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 031d2d4d4768..1f3d5329fcbd 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -4,6 +4,7 @@ import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; +import assert from 'assert'; import polka from 'polka'; import invariant from 'tiny-invariant'; @@ -28,9 +29,11 @@ export async function storybookDevServer(options: Options) { const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]); const app = polka({ server }); + assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel'); + const serverChannel = await options.presets.apply( 'experimental_serverChannel', - getServerChannel(server, core?.channelOptions?.wsToken) + getServerChannel(server, core.channelOptions.wsToken) ); const workingDir = process.cwd(); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d7422c13469f..2f9330515d27 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -32,7 +32,7 @@ export interface CoreConfig { }; renderer?: RendererName; disableWebpackDefaults?: boolean; - channelOptions?: Partial; + channelOptions?: Partial & { wsToken?: string }; /** Disables the generation of project.json, a file containing Storybook metadata */ disableProjectJson?: boolean; /** From de132debcd33a746d84652d1d134179f0f9c06eb Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 10:20:20 +0100 Subject: [PATCH 4/8] Reduce the number of VCPUs for the CI check task from 3 to 2 to optimize resource usage. --- scripts/tasks/compile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index 6653990ce765..eaa4ef2a699f 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -7,7 +7,7 @@ import { exec } from '../utils/exec'; import { maxConcurrentTasks } from '../utils/maxConcurrentTasks'; // The amount of VCPUs for the check task on CI is 4 (large resource) -const amountOfVCPUs = 3; +const amountOfVCPUs = 2; const parallel = `--parallel=${process.env.CI ? amountOfVCPUs - 1 : maxConcurrentTasks}`; From d34be5f7b85749f04b42bb438e815568b60e1059 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 10:55:42 +0100 Subject: [PATCH 5/8] Minor adjustments --- code/core/src/builder-manager/utils/framework.ts | 2 +- .../src/core-server/utils/__tests__/server-channel.test.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index 7bf7309f12ec..6d61b3d9ad1d 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -19,7 +19,7 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const renderer = frameworkToRenderer[framework]; // Manager only needs the token currently, so we don't pass any other channel options. - globals.CHANNEL_OPTIONS = { wsToken: channelOptions.wsToken }; + globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken }; globals.STORYBOOK_BUILDER = builder; globals.STORYBOOK_FRAMEWORK = framework; globals.STORYBOOK_RENDERER = renderer; diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index fe17cd1f6185..24b13030eb24 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -81,7 +81,6 @@ describe('ServerChannelTransport', () => { socket.write = vi.fn(); socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); - const transport = new ServerChannelTransport(server, mockToken); // Simulate upgrade request without token const request = { @@ -89,7 +88,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).toHaveBeenCalledWith( @@ -112,7 +110,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).toHaveBeenCalledWith( @@ -140,7 +137,6 @@ describe('ServerChannelTransport', () => { } as any; const head = Buffer.from(''); - // @ts-expect-error (accessing private method via upgrade handler) server.listeners('upgrade')[0](request, socket, head); expect(socket.write).not.toHaveBeenCalled(); From f1d8f96ccaa08c9a66f5b8491a26cfac2c8d32f4 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 11:27:13 +0100 Subject: [PATCH 6/8] Remove test --- .../utils/__tests__/server-channel.test.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index 24b13030eb24..db7b73ba1de0 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -75,34 +75,13 @@ describe('ServerChannelTransport', () => { `); }); - it('rejects connections without 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'); - - // Simulate upgrade request without token - const request = { - url: '/storybook-server-channel', - } 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('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'); - const transport = new ServerChannelTransport(server, mockToken); + new ServerChannelTransport(server, mockToken); // Simulate upgrade request with wrong token const request = { From 4dcbee69c05e931ea28cbc721676bda2e90deb15 Mon Sep 17 00:00:00 2001 From: yannbf Date: Fri, 13 Feb 2026 12:06:55 +0100 Subject: [PATCH 7/8] enfore security on ws connection for build mode --- code/core/src/builder-manager/utils/framework.ts | 6 ++++-- code/core/src/core-server/presets/common-preset.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index 6d61b3d9ad1d..df89e68a6336 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -18,8 +18,10 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const framework = frameworkPackages[frameworkPackageName]; const renderer = frameworkToRenderer[framework]; - // Manager only needs the token currently, so we don't pass any other channel options. - globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken }; + if (options.configType === 'DEVELOPMENT') { + // Manager only needs the token currently, so we don't pass any other channel options. + globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken }; + } globals.STORYBOOK_BUILDER = builder; globals.STORYBOOK_FRAMEWORK = framework; globals.STORYBOOK_RENDERER = renderer; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 10eadc1b4e03..bc1e172fa390 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -194,7 +194,10 @@ export const experimental_serverAPI = (extension: Record, opti const wsToken = randomUUID(); export const core = async (existing: CoreConfig, options: Options): Promise => ({ ...existing, - channelOptions: { ...(existing?.channelOptions ?? {}), wsToken }, + channelOptions: { + ...(existing?.channelOptions ?? {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + }, disableTelemetry: options.disableTelemetry === true, enableCrashReports: options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS), From 1ef385e7d86a36e8db410e2094b0213b93eff1f3 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Feb 2026 08:57:16 +0100 Subject: [PATCH 8/8] Type fix --- code/core/src/core-server/utils/validate-websocket-token.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/validate-websocket-token.ts b/code/core/src/core-server/utils/validate-websocket-token.ts index ec4ff5796cc1..fc5c29b802b8 100644 --- a/code/core/src/core-server/utils/validate-websocket-token.ts +++ b/code/core/src/core-server/utils/validate-websocket-token.ts @@ -13,7 +13,8 @@ export function isValidToken(token: string | null, expectedToken: string): boole const a = Buffer.from(token, 'utf8'); const b = Buffer.from(expectedToken, 'utf8'); try { - return a.length === b.length && timingSafeEqual(a, b); + // 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; }