Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
05327b2
Docs: fix broken CSF doc links
pavan-sh Feb 6, 2026
cf8ffe3
Merge branch 'next' into docs/fix-csf-links-only
pavan-sh Feb 7, 2026
e313ba6
Docs: point monorepo links to next branch
pavan-sh Feb 8, 2026
52e9ad9
Docs: fix build command and typo in main config build
pavan-sh Feb 10, 2026
eec468d
Merge branch 'next' into docs/fix-build-config-command
pavan-sh Feb 10, 2026
a4436cc
Refactor channel options handling and implement token validation for …
ghengeveld Feb 10, 2026
58ed3df
Rename 'token' to 'wsToken' for clarity
ghengeveld Feb 10, 2026
38aee8d
Support inferring props in preact
JoviDeCroock Feb 12, 2026
ab875ea
Address automated feedback
JoviDeCroock Feb 12, 2026
2f57083
Merge branch 'next' into harden-websocket-security
valentinpalkovic Feb 13, 2026
d99c7b9
Enhance websocket security by requiring wsToken in channelOptions and…
valentinpalkovic Feb 13, 2026
de132de
Reduce the number of VCPUs for the CI check task from 3 to 2 to optim…
valentinpalkovic Feb 13, 2026
03ff85b
Fix prettier formatting in public-types.test.tsx
kasperpeulen Feb 13, 2026
d34be5f
Minor adjustments
valentinpalkovic Feb 13, 2026
f1d8f96
Remove test
valentinpalkovic Feb 13, 2026
426ea04
Merge branch 'next' into add-preact-inferred-types
kasperpeulen Feb 13, 2026
f242e76
Merge pull request #33828 from JoviDeCroock/add-preact-inferred-types
kasperpeulen Feb 13, 2026
4dcbee6
enfore security on ws connection for build mode
yannbf Feb 13, 2026
e6b8965
Merge branch 'next' into harden-websocket-security
valentinpalkovic Feb 13, 2026
ec1169c
Merge branch 'next' into docs/fix-csf-links-only
jonniebigodes Feb 15, 2026
a57390d
Merge pull request #33793 from pavan-sh/docs/fix-csf-links-only
jonniebigodes Feb 15, 2026
c915f09
Update docs/builders/builder-api.mdx
pavan-sh Feb 16, 2026
e0c40ea
Update docs/api/new-frameworks.mdx
pavan-sh Feb 16, 2026
b5c4196
Update docs/api/new-frameworks.mdx
pavan-sh Feb 16, 2026
db89e0e
Merge branch 'next-release' into next
storybook-bot Feb 16, 2026
9eee4dd
Update docs/api/main-config/main-config-build.mdx
pavan-sh Feb 16, 2026
d559fe2
Merge branch 'next' into docs/fix-build-config-command
jonniebigodes Feb 16, 2026
b8d877c
Merge pull request #33816 from pavan-sh/docs/fix-build-config-command
jonniebigodes Feb 16, 2026
9d4572e
Merge branch 'next' into docs/fix-docs-github-links-next
jonniebigodes Feb 16, 2026
21583ee
Merge pull request #33799 from pavan-sh/docs/fix-docs-github-links-next
jonniebigodes Feb 16, 2026
294e870
Docs: Adjust builder API snippet
jonniebigodes Feb 16, 2026
24db05a
Merge pull request #33852 from storybookjs/docs_fix_builder_snippet
jonniebigodes Feb 16, 2026
db31b07
Enhance MockLink component to support legacy behavior with child elem…
yatishgoel Feb 18, 2026
1ef385e
Type fix
valentinpalkovic Feb 18, 2026
5f8dace
Merge pull request #33820 from storybookjs/harden-websocket-security
valentinpalkovic Feb 18, 2026
e043b98
Merge pull request #33862 from yatishgoel/fix/nextjs-link-legacy-beha…
valentinpalkovic Feb 18, 2026
45a6cbe
Write changelog for 10.3.0-alpha.7 [skip ci]
storybook-bot Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.prerelease.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 10.3.0-alpha.7

- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!
- Next.js: Handle legacyBehavior prop in Link mock component - [#33862](https://github.com/storybookjs/storybook/pull/33862), thanks @yatishgoel!
- Preact: Support inferring props from component types - [#33828](https://github.com/storybookjs/storybook/pull/33828), thanks @JoviDeCroock!

## 10.3.0-alpha.6

- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic!
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dirname, join, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import {
Expand Down
8 changes: 6 additions & 2 deletions code/core/src/builder-manager/utils/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions code/core/src/channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 }));
}
Expand Down
7 changes: 4 additions & 3 deletions code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand All @@ -52,8 +55,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());

Expand Down
10 changes: 10 additions & 0 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -257,6 +263,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
Expand Down
62 changes: 57 additions & 5 deletions code/core/src/core-server/utils/__tests__/server-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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();
});
});
32 changes: 24 additions & 8 deletions code/core/src/core-server/utils/get-server-channel.ts
Original file line number Diff line number Diff line change
@@ -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']>;

Expand All @@ -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) => {
Expand Down Expand Up @@ -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 });

Expand Down
4 changes: 0 additions & 4 deletions code/core/src/core-server/utils/getAccessControlMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
5 changes: 5 additions & 0 deletions code/core/src/core-server/utils/index-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions code/core/src/core-server/utils/validate-websocket-token.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion code/core/src/types/modules/core-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
21 changes: 21 additions & 0 deletions code/frameworks/nextjs/src/export-mocks/link/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ const MockLink = React.forwardRef<HTMLAnchorElement, any>(function MockLink(
linkAction(hrefString, { replace, scroll, shallow, prefetch, locale });
};

if (legacyBehavior) {
const child = React.Children.only(children) as React.ReactElement<any>;
const childProps: Record<string, any> = {
ref,
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (child.props && typeof child.props.onClick === 'function') {
child.props.onClick(e);
}
linkAction(hrefString, { replace, scroll, shallow, prefetch, locale });
},
...rest,
};

if (passHref || (child.type === 'a' && !('href' in (child.props || {})))) {
childProps.href = hrefString;
}

return React.cloneElement(child, childProps);
}

return (
<a ref={ref} href={hrefString} onClick={handleClick} {...rest}>
{children}
Expand Down
3 changes: 2 additions & 1 deletion code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,5 +220,6 @@
"Dependency Upgrades"
]
]
}
},
"deferredNextVersion": "10.3.0-alpha.7"
}
Loading