Skip to content

Commit 0affdf9

Browse files
Merge pull request #33860 from storybookjs/hotfix/v9.1.19
Core: Harden websocket connection
2 parents bbe61e3 + 66b2d8e commit 0affdf9

File tree

12 files changed

+155
-24
lines changed

12 files changed

+155
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 9.1.19
2+
3+
- Harden websocket connection
4+
15
## 9.1.18
26

37
- No-op release. No changes.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ export const pluckThirdPartyPackageFromPath = (packagePath: string) =>
3333
export const buildFrameworkGlobalsFromOptions = async (options: Options) => {
3434
const globals: Record<string, any> = {};
3535

36-
const { builder } = await options.presets.apply('core');
36+
const { builder, channelOptions } = await options.presets.apply('core');
3737

3838
const frameworkName = await getFrameworkName(options);
3939
const rendererName = await extractProperRendererNameFromFramework(frameworkName);
4040

41+
if (options.configType === 'DEVELOPMENT') {
42+
// Manager only needs the token currently, so we don't pass any other channel options.
43+
globals.CHANNEL_OPTIONS = { wsToken: channelOptions?.wsToken };
44+
}
45+
4146
if (rendererName) {
4247
globals.STORYBOOK_RENDERER =
4348
(await extractProperRendererNameFromFramework(frameworkName)) ?? undefined;

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 & 1 deletion
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

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

30+
assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel');
31+
2932
const serverChannel = await options.presets.apply(
3033
'experimental_serverChannel',
31-
getServerChannel(server)
34+
getServerChannel(server, core.channelOptions.wsToken)
3235
);
3336

3437
let indexError: Error | undefined;

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

Lines changed: 10 additions & 1 deletion
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
import { dirname, isAbsolute, join } from 'node:path';
@@ -181,7 +182,7 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti
181182
}
182183
return { ...extension, removeAddon };
183184
};
184-
185+
const wsToken = randomUUID();
185186
/**
186187
* If for some reason this config is not applied, the reason is that likely there is an addon that
187188
* does `export core = () => ({ someConfig })`, instead of `export core = (existing) => ({
@@ -191,6 +192,10 @@ export const experimental_serverAPI = (extension: Record<string, Function>, opti
191192
export const core = async (existing: CoreConfig, options: Options): Promise<CoreConfig> => ({
192193
...existing,
193194
disableTelemetry: options.disableTelemetry === true,
195+
channelOptions: {
196+
...(existing?.channelOptions ?? {}),
197+
...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}),
198+
},
194199
enableCrashReports:
195200
options.enableCrashReports || optionalEnvToBoolean(process.env.STORYBOOK_ENABLE_CRASH_REPORTS),
196201
});
@@ -247,6 +252,10 @@ export const managerHead = async (_: any, options: Options) => {
247252
return '';
248253
};
249254

255+
export const channelToken = async (value: string | undefined) => {
256+
return value;
257+
};
258+
250259
export const experimental_serverChannel = async (
251260
channel: Channel,
252261
options: OptionsWithRequiredCache

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

Lines changed: 76 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,71 @@ describe('ServerChannelTransport', () => {
7074
}
7175
`);
7276
});
77+
78+
it('skips telejson classes and functions in data', () => {
79+
const server = new EventEmitter() as any as Server;
80+
const socket = new EventEmitter();
81+
const transport = new ServerChannelTransport(server, mockToken);
82+
const handler = vi.fn();
83+
transport.setHandler(handler);
84+
85+
// @ts-expect-error (an internal API)
86+
transport.socket.emit('connection', socket);
87+
88+
const input = { a() {}, b: class {}, c: true, d: 3 };
89+
socket.emit('message', stringify(input));
90+
91+
console.log(handler.mock.calls);
92+
93+
expect(handler.mock.calls[0][0].a).toBeUndefined();
94+
expect(handler.mock.calls[0][0].b).toBeUndefined();
95+
});
96+
97+
it('rejects connections with invalid token', () => {
98+
const server = new EventEmitter() as any as Server;
99+
const socket = new EventEmitter() as any;
100+
socket.write = vi.fn();
101+
socket.destroy = vi.fn();
102+
const destroySpy = vi.spyOn(socket, 'destroy');
103+
new ServerChannelTransport(server, mockToken);
104+
105+
// Simulate upgrade request with wrong token
106+
const request = {
107+
url: '/storybook-server-channel?token=wrong-token',
108+
} as any;
109+
const head = Buffer.from('');
110+
111+
server.listeners('upgrade')[0](request, socket, head);
112+
113+
expect(socket.write).toHaveBeenCalledWith(
114+
'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'
115+
);
116+
expect(destroySpy).toHaveBeenCalled();
117+
});
118+
119+
it('accepts connections with valid token', () => {
120+
const server = new EventEmitter() as any as Server;
121+
const socket = new EventEmitter() as any;
122+
socket.write = vi.fn();
123+
socket.destroy = vi.fn();
124+
const destroySpy = vi.spyOn(socket, 'destroy');
125+
const handleUpgradeSpy = vi.fn();
126+
const transport = new ServerChannelTransport(server, mockToken);
127+
128+
// Mock handleUpgrade to track if it's called
129+
// @ts-expect-error (accessing private property)
130+
transport.socket.handleUpgrade = handleUpgradeSpy;
131+
132+
// Simulate upgrade request with correct token
133+
const request = {
134+
url: `/storybook-server-channel?token=${mockToken}`,
135+
} as any;
136+
const head = Buffer.from('');
137+
138+
server.listeners('upgrade')[0](request, socket, head);
139+
140+
expect(socket.write).not.toHaveBeenCalled();
141+
expect(destroySpy).not.toHaveBeenCalled();
142+
expect(handleUpgradeSpy).toHaveBeenCalled();
143+
});
73144
});

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/stories-json.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ export function useStoriesJson({
6262
const generator = await initializedStoryIndexGenerator;
6363
const index = await generator.getIndex();
6464
res.setHeader('Content-Type', 'application/json');
65+
res.setHeader('Access-Control-Allow-Origin', '*');
66+
res.setHeader(
67+
'Access-Control-Allow-Headers',
68+
'Origin, X-Requested-With, Content-Type, Accept'
69+
);
6570
res.end(JSON.stringify(index));
6671
} catch (err) {
6772
res.statusCode = 500;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { timingSafeEqual } from 'node:crypto';
2+
3+
/**
4+
* Validates a secret token using constant-time comparison to prevent timing attacks.
5+
*
6+
* @returns `true` if tokens match, `false` otherwise
7+
*/
8+
export function isValidToken(token: string | null, expectedToken: string): boolean {
9+
if (!token || !expectedToken) {
10+
return false;
11+
}
12+
13+
const a = Buffer.from(token, 'utf8');
14+
const b = Buffer.from(expectedToken, 'utf8');
15+
try {
16+
return a.length === b.length && timingSafeEqual(a, b);
17+
} catch {
18+
return false;
19+
}
20+
}

0 commit comments

Comments
 (0)