Skip to content

Commit ebc10a8

Browse files
authored
Merge pull request #33851 from storybookjs/version-non-patch-from-10.3.0-alpha.6
Release: Prerelease 10.3.0-alpha.7
2 parents 34f1e42 + 45a6cbe commit ebc10a8

File tree

37 files changed

+468
-67
lines changed

37 files changed

+468
-67
lines changed

CHANGELOG.prerelease.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 10.3.0-alpha.7
2+
3+
- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!
4+
- Next.js: Handle legacyBehavior prop in Link mock component - [#33862](https://github.com/storybookjs/storybook/pull/33862), thanks @yatishgoel!
5+
- Preact: Support inferring props from component types - [#33828](https://github.com/storybookjs/storybook/pull/33828), thanks @JoviDeCroock!
6+
17
## 10.3.0-alpha.6
28

39
- 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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { dirname, join, resolve } from 'node:path';
1+
import { join, resolve } from 'node:path';
22
import { fileURLToPath } from 'node:url';
33

44
import {

code/core/src/builder-manager/utils/framework.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
import { type Options, SupportedBuilder } from 'storybook/internal/types';
88

99
export const buildFrameworkGlobalsFromOptions = async (options: Options) => {
10-
const globals: Record<string, string | undefined> = {};
10+
const globals: Record<string, any> = {};
1111

12-
const builderConfig = (await options.presets.apply('core')).builder;
12+
const { builder: builderConfig, channelOptions } = await options.presets.apply('core');
1313
const builderName = typeof builderConfig === 'string' ? builderConfig : builderConfig?.name;
1414
const builder = Object.values(SupportedBuilder).find((builder) => builderName?.includes(builder));
1515

@@ -18,6 +18,10 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => {
1818
const framework = frameworkPackages[frameworkPackageName];
1919
const renderer = frameworkToRenderer[framework];
2020

21+
if (options.configType === 'DEVELOPMENT') {
22+
// Manager only needs the token currently, so we don't pass any other channel options.
23+
globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken };
24+
}
2125
globals.STORYBOOK_BUILDER = builder;
2226
globals.STORYBOOK_FRAMEWORK = framework;
2327
globals.STORYBOOK_RENDERER = renderer;

code/core/src/channels/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PostMessageTransport } from './postmessage';
77
import type { ChannelTransport, Config } from './types';
88
import { WebsocketTransport } from './websocket';
99

10-
const { CONFIG_TYPE } = global;
10+
const { CHANNEL_OPTIONS, CONFIG_TYPE } = global;
1111

1212
export * from './main';
1313

@@ -35,7 +35,8 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C
3535
if (CONFIG_TYPE === 'DEVELOPMENT') {
3636
const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss';
3737
const { hostname, port } = window.location;
38-
const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel`;
38+
const { wsToken } = CHANNEL_OPTIONS || {};
39+
const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${wsToken}`;
3940

4041
transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page }));
4142
}

code/core/src/core-server/dev-server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MissingBuilderError } from 'storybook/internal/server-errors';
44
import type { Options } from 'storybook/internal/types';
55

66
import compression from '@polka/compression';
7+
import assert from 'assert';
78
import polka from 'polka';
89
import invariant from 'tiny-invariant';
910

@@ -28,9 +29,11 @@ export async function storybookDevServer(options: Options) {
2829
const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]);
2930
const app = polka({ server });
3031

32+
assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel');
33+
3134
const serverChannel = await options.presets.apply(
3235
'experimental_serverChannel',
33-
getServerChannel(server)
36+
getServerChannel(server, core.channelOptions.wsToken)
3437
);
3538

3639
const workingDir = process.cwd();
@@ -52,8 +55,6 @@ export async function storybookDevServer(options: Options) {
5255
options.extendServer(server);
5356
}
5457

55-
// CORS middleware must be registered BEFORE route handlers to ensure all routes
56-
// (including /index.json) receive proper CORS headers for Storybook Composition
5758
app.use(getAccessControlMiddleware(core?.crossOriginIsolated ?? false));
5859
app.use(getCachingMiddleware());
5960

code/core/src/core-server/presets/common-preset.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from 'node:crypto';
12
import { existsSync } from 'node:fs';
23
import { readFile } from 'node:fs/promises';
34

@@ -190,8 +191,13 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti
190191
* ...existing, someConfig })`, just overwriting everything and not merging with the existing
191192
* values.
192193
*/
194+
const wsToken = randomUUID();
193195
export const core = async (existing: CoreConfig, options: Options): Promise<CoreConfig> => ({
194196
...existing,
197+
channelOptions: {
198+
...(existing?.channelOptions ?? {}),
199+
...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}),
200+
},
195201
disableTelemetry: options.disableTelemetry === true,
196202
enableCrashReports:
197203
options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS),
@@ -257,6 +263,10 @@ export const managerHead = async (_: any, options: Options) => {
257263
return '';
258264
};
259265

266+
export const channelToken = async (value: string | undefined) => {
267+
return value;
268+
};
269+
260270
export const experimental_serverChannel = async (
261271
channel: Channel,
262272
options: OptionsWithRequiredCache

code/core/src/core-server/utils/__tests__/server-channel.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,24 @@ import { ServerChannelTransport, getServerChannel } from '../get-server-channel'
1111
describe('getServerChannel', () => {
1212
it('should return a channel', () => {
1313
const server = { on: vi.fn() } as any as Server;
14-
const result = getServerChannel(server);
14+
const result = getServerChannel(server, 'test-token-123');
1515
expect(result).toBeInstanceOf(Channel);
1616
});
1717

1818
it('should attach to the http server', () => {
1919
const server = { on: vi.fn() } as any as Server;
20-
getServerChannel(server);
20+
getServerChannel(server, 'test-token-123');
2121
expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function));
2222
});
2323
});
2424

2525
describe('ServerChannelTransport', () => {
26+
const mockToken = 'test-token-123';
27+
2628
it('parses simple JSON', () => {
2729
const server = new EventEmitter() as any as Server;
2830
const socket = new EventEmitter();
29-
const transport = new ServerChannelTransport(server);
31+
const transport = new ServerChannelTransport(server, mockToken);
3032
const handler = vi.fn();
3133
transport.setHandler(handler);
3234

@@ -36,10 +38,11 @@ describe('ServerChannelTransport', () => {
3638

3739
expect(handler).toHaveBeenCalledWith('hello');
3840
});
41+
3942
it('parses object JSON', () => {
4043
const server = new EventEmitter() as any as Server;
4144
const socket = new EventEmitter();
42-
const transport = new ServerChannelTransport(server);
45+
const transport = new ServerChannelTransport(server, mockToken);
4346
const handler = vi.fn();
4447
transport.setHandler(handler);
4548

@@ -49,10 +52,11 @@ describe('ServerChannelTransport', () => {
4952

5053
expect(handler).toHaveBeenCalledWith({ type: 'hello' });
5154
});
55+
5256
it('supports telejson cyclical data', () => {
5357
const server = new EventEmitter() as any as Server;
5458
const socket = new EventEmitter();
55-
const transport = new ServerChannelTransport(server);
59+
const transport = new ServerChannelTransport(server, mockToken);
5660
const handler = vi.fn();
5761
transport.setHandler(handler);
5862

@@ -70,4 +74,52 @@ describe('ServerChannelTransport', () => {
7074
}
7175
`);
7276
});
77+
78+
it('rejects connections with invalid token', () => {
79+
const server = new EventEmitter() as any as Server;
80+
const socket = new EventEmitter() as any;
81+
socket.write = vi.fn();
82+
socket.destroy = vi.fn();
83+
const destroySpy = vi.spyOn(socket, 'destroy');
84+
new ServerChannelTransport(server, mockToken);
85+
86+
// Simulate upgrade request with wrong token
87+
const request = {
88+
url: '/storybook-server-channel?token=wrong-token',
89+
} as any;
90+
const head = Buffer.from('');
91+
92+
server.listeners('upgrade')[0](request, socket, head);
93+
94+
expect(socket.write).toHaveBeenCalledWith(
95+
'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'
96+
);
97+
expect(destroySpy).toHaveBeenCalled();
98+
});
99+
100+
it('accepts connections with valid token', () => {
101+
const server = new EventEmitter() as any as Server;
102+
const socket = new EventEmitter() as any;
103+
socket.write = vi.fn();
104+
socket.destroy = vi.fn();
105+
const destroySpy = vi.spyOn(socket, 'destroy');
106+
const handleUpgradeSpy = vi.fn();
107+
const transport = new ServerChannelTransport(server, mockToken);
108+
109+
// Mock handleUpgrade to track if it's called
110+
// @ts-expect-error (accessing private property)
111+
transport.socket.handleUpgrade = handleUpgradeSpy;
112+
113+
// Simulate upgrade request with correct token
114+
const request = {
115+
url: `/storybook-server-channel?token=${mockToken}`,
116+
} as any;
117+
const head = Buffer.from('');
118+
119+
server.listeners('upgrade')[0](request, socket, head);
120+
121+
expect(socket.write).not.toHaveBeenCalled();
122+
expect(destroySpy).not.toHaveBeenCalled();
123+
expect(handleUpgradeSpy).toHaveBeenCalled();
124+
});
73125
});

code/core/src/core-server/utils/get-server-channel.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import type { IncomingMessage } from 'node:http';
2+
13
import type { ChannelHandler } from 'storybook/internal/channels';
24
import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels';
35

46
import { isJSON, parse, stringify } from 'telejson';
57
import WebSocket, { WebSocketServer } from 'ws';
68

79
import { UniversalStore } from '../../shared/universal-store';
10+
import { isValidToken } from './validate-websocket-token';
811

912
type Server = NonNullable<NonNullable<ConstructorParameters<typeof WebSocketServer>[0]>['server']>;
1013

@@ -19,14 +22,27 @@ export class ServerChannelTransport {
1922

2023
private handler?: ChannelHandler;
2124

22-
constructor(server: Server) {
25+
private token: string;
26+
27+
constructor(server: Server, token: string) {
28+
this.token = token;
2329
this.socket = new WebSocketServer({ noServer: true });
2430

25-
server.on('upgrade', (request, socket, head) => {
26-
if (request.url === '/storybook-server-channel') {
27-
this.socket.handleUpgrade(request, socket, head, (ws) => {
28-
this.socket.emit('connection', ws, request);
29-
});
31+
server.on('upgrade', (request: IncomingMessage, socket, head) => {
32+
if (request.url) {
33+
const url = new URL(request.url, 'http://localhost');
34+
if (url.pathname === '/storybook-server-channel') {
35+
const requestToken = url.searchParams.get('token');
36+
if (!isValidToken(requestToken, this.token)) {
37+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
38+
socket.destroy();
39+
return;
40+
}
41+
42+
this.socket.handleUpgrade(request, socket, head, (ws) => {
43+
this.socket.emit('connection', ws, request);
44+
});
45+
}
3046
}
3147
});
3248
this.socket.on('connection', (wss) => {
@@ -68,8 +84,8 @@ export class ServerChannelTransport {
6884
}
6985
}
7086

71-
export function getServerChannel(server: Server) {
72-
const transports = [new ServerChannelTransport(server)];
87+
export function getServerChannel(server: Server, token: string) {
88+
const transports = [new ServerChannelTransport(server, token)];
7389

7490
const channel = new Channel({ transports, async: true });
7591

code/core/src/core-server/utils/getAccessControlMiddleware.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@ import type { Middleware } from '../../types';
22

33
export function getAccessControlMiddleware(crossOriginIsolated: boolean): Middleware {
44
return (req, res, next) => {
5-
res.setHeader('Access-Control-Allow-Origin', '*');
6-
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
75
// These headers are required to enable SharedArrayBuffer
86
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
97
if (crossOriginIsolated) {
10-
// These headers are required to enable SharedArrayBuffer
11-
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
128
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
139
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
1410
}

code/core/src/core-server/utils/index-json.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export function registerIndexJsonRoute({
5858
try {
5959
const index = await (await storyIndexGeneratorPromise).getIndex();
6060
res.setHeader('Content-Type', 'application/json');
61+
res.setHeader('Access-Control-Allow-Origin', '*');
62+
res.setHeader(
63+
'Access-Control-Allow-Headers',
64+
'Origin, X-Requested-With, Content-Type, Accept'
65+
);
6166
res.end(JSON.stringify(index));
6267
} catch (err) {
6368
res.statusCode = 500;

0 commit comments

Comments
 (0)