diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index eed68db653cb..73a7eaad10a9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -33,4 +33,4 @@ jobs: install-code-deps: true - name: Compile - run: yarn nx run-many --targets compile + run: yarn nx run-many --targets compile --no-cloud diff --git a/CHANGELOG.md b/CHANGELOG.md index aff4d1aca789..e47ac3da22d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 10.2.14 + +- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf! +- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000! + ## 10.2.13 - Addon Pseudo-states: Process all nested css rules - [#33605](https://github.com/storybookjs/storybook/pull/33605), thanks @hpohlmeyer! diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 1924a7e280f3..ed6352042df4 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,13 @@ +## 10.3.0-alpha.14 + +- CSF-Factories: Fix ConfigFile parser false warning on `definePreview({...}).type()` export default - [#33885](https://github.com/storybookjs/storybook/pull/33885), thanks @copilot-swe-agent! +- Core: Add host/origin validation to requests and websocket connections - [#33835](https://github.com/storybookjs/storybook/pull/33835), thanks @ghengeveld! +- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates! +- Core: Zoom tool refinements - Hide reset button when value is initial - [#33635](https://github.com/storybookjs/storybook/pull/33635), thanks @superLipbalm! +- Docs: Edit JSON button is now accessible at 320x256 viewport (WCAG 2.1 Reflow test) - [#33707](https://github.com/storybookjs/storybook/pull/33707), thanks @TheSeydiCharyyev! +- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319! +- UI: Allow direct kb/mouse actions on zoom tool button - [#33496](https://github.com/storybookjs/storybook/pull/33496), thanks @Sidnioulz! + ## 10.3.0-alpha.13 - A11y: Add ScrollArea prop focusable for when it has static children - [#33876](https://github.com/storybookjs/storybook/pull/33876), thanks @Sidnioulz! diff --git a/SECURITY.md b/SECURITY.md index 1e3ad889e024..d34652bb0f0a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,13 @@ ## Supported Versions -We release patches for fixing security vulnerabilities, primarily focusing on the latest release only. +We release patches for security vulnerabilities, primarily focusing on the latest major version. -In the event of a high-risk vulnerability, we may backport the security fixes to the minor versions of the software, starting from the latest minor version up to the latest major release. The decision to backport security fixes to older versions will be made based on a risk assessment and the feasibility of implementing the patch in those versions. +Security fixes are backported to the previous two major versions only for vulnerabilities with High or Critical CVSS scores (7.0+). The decision to backport is made based on severity assessment and the feasibility of implementing the patch in those versions. + +- Latest major version: All security vulnerabilities +- Previous two major versions: High or Critical CVSS scores only +- Older versions: Not supported (Users should upgrade to a supported version) ## Reporting a Vulnerability diff --git a/code/addons/docs/src/blocks/controls/Object.stories.tsx b/code/addons/docs/src/blocks/controls/Object.stories.tsx index de883570198b..50146d080f31 100644 --- a/code/addons/docs/src/blocks/controls/Object.stories.tsx +++ b/code/addons/docs/src/blocks/controls/Object.stories.tsx @@ -112,3 +112,31 @@ export const ReadonlyAndUndefined: Story = { argType: { table: { readonly: true } }, }, }; + +export const ObjectSmallViewport: Story = { + args: { + value: { + name: 'Michael', + someDate: new Date('2022-10-30T12:31:11'), + nested: { someBool: true, someNumber: 22 }, + }, + }, + parameters: { + chromatic: { viewports: [320] }, + }, +}; + +export const ArraySmallViewport: Story = { + args: { + value: [ + 'someString', + 22, + true, + new Date('2022-10-30T12:31:11'), + { someBool: true, someNumber: 22 }, + ], + }, + parameters: { + chromatic: { viewports: [320] }, + }, +}; diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index dfaf5677ef62..e63b63d7f9a7 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Form, ToggleButton } from 'storybook/internal/components'; -import { AddIcon, SubtractIcon } from '@storybook/icons'; +import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; import { styled, useTheme } from 'storybook/theming'; @@ -18,8 +18,10 @@ const Wrapper = styled.div(({ theme }) => ({ position: 'relative', display: 'flex', isolation: 'isolate', + gap: 8, '.rejt-tree': { + flex: 1, marginLeft: '1rem', fontSize: '13px', listStyleType: 'none', @@ -125,10 +127,9 @@ const Input = styled.input(({ theme, placeholder }) => ({ })); const RawButton = styled(ToggleButton)({ - position: 'absolute', - zIndex: 2, - top: 2, - right: 2, + alignSelf: 'flex-start', + order: 2, + marginRight: -10, }); const RawInput = styled(Form.Textarea)(({ theme }) => ({ @@ -188,7 +189,7 @@ export const ObjectControl: FC = ({ name, value, onChange, argType const onForceVisible = useCallback(() => { onChange({}); setForceVisible(true); - }, [setForceVisible]); + }, [onChange, setForceVisible]); const htmlElRef = useRef(null); useEffect(() => { @@ -240,13 +241,16 @@ export const ObjectControl: FC = ({ name, value, onChange, argType { e.preventDefault(); setShowRaw((isRaw) => !isRaw); }} + variant="ghost" + padding="small" + size="small" > - Edit JSON + )} {!showRaw ? ( diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index a3a671c4c226..e8022b796202 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -1,8 +1,6 @@ -import { logger } from 'storybook/internal/node-logger'; import type { Options } from 'storybook/internal/types'; import type { Server } from 'http'; -import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; import { createViteLogger } from './logger'; @@ -13,9 +11,12 @@ export async function createViteServer(options: Options, devServer: Server) { const commonCfg = await commonConfig(options, 'development'); + const { allowedHosts } = await presets.apply('core', {}); + const config: InlineConfig & { server: ServerOptions } = { ...commonCfg, server: { + allowedHosts, middlewareMode: true, hmr: { port: options.port, @@ -28,18 +29,12 @@ export async function createViteServer(options: Options, devServer: Server) { appType: 'custom' as const, }; - // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. - // but without server.allowedHosts set, requests from outside the container will be rejected. - if (options.host === '0.0.0.0' && !config.server.allowedHosts) { + // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments + if ( + options.host === '0.0.0.0' && + (!allowedHosts || (Array.isArray(allowedHosts) && allowedHosts.length === 0)) + ) { config.server.allowedHosts = true; - logger.warn(dedent`'host' is set to '0.0.0.0' but 'allowedHosts' is not defined. - Defaulting 'allowedHosts' to true, which permits all hostnames. - To restrict allowed hostnames, add the following to your 'viteFinal' config: - Example: { server: { allowedHosts: ['mydomain.com'] } } - See: - - https://vite.dev/config/server-options.html#server-allowedhosts - - https://storybook.js.org/docs/api/main-config/main-config-vite-final - `); } const finalConfig = await presets.apply('viteFinal', config, options); diff --git a/code/core/package.json b/code/core/package.json index c8a0dc217d7e..2c962f1f827e 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -320,6 +320,7 @@ "get-npm-tarball-url": "^2.1.0", "glob": "^10.5.0", "globby": "^14.1.0", + "host-validation-middleware": "^0.1.2", "jiti": "^2.6.1", "js-yaml": "^4.1.0", "jsdoc-type-pratt-parser": "^4.0.0", diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 27fbd87539f0..bba61a8f6ddd 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -23,6 +23,7 @@ import { join, relative, resolve } from 'pathe'; import invariant from 'tiny-invariant'; import { dedent } from 'ts-dedent'; +import Channel from '../channels'; import { detectPnp } from '../cli/detect'; import { resolvePackageDir } from '../shared/utils/module'; import { storybookDevServer } from './dev-server'; @@ -32,7 +33,7 @@ import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getServerChannel } from './utils/get-server-channel'; import { outputStartupInformation } from './utils/output-startup-information'; import { outputStats } from './utils/output-stats'; -import { getServerChannelUrl, getServerPort } from './utils/server-address'; +import { getServerAddresses, getServerChannelUrl, getServerPort } from './utils/server-address'; import { getServer } from './utils/server-init'; import { stripCommentsAndStrings } from './utils/strip-comments-and-strings'; import { updateCheck } from './utils/update-check'; @@ -91,6 +92,14 @@ export async function buildDevStandalone( outputDir = cacheOutputDir; } + invariant(port, 'expected options to have a port'); + const { address: localAddress, networkAddress } = getServerAddresses( + port, + options.host, + options.https ? 'https' : 'http', + options.initialPath + ); + options.port = port; options.versionCheck = versionCheck; options.configType = 'DEVELOPMENT'; @@ -98,6 +107,8 @@ export async function buildDevStandalone( options.cacheKey = cacheKey; options.outputDir = outputDir; options.serverChannelUrl = getServerChannelUrl(port, options); + options.localAddress = localAddress; + options.networkAddress = networkAddress; // TODO: Remove in SB11 options.pnp = await detectPnp(); @@ -111,7 +122,7 @@ export async function buildDevStandalone( } const config = await loadMainConfig(options); - const { framework } = config; + const { core, framework } = config; const corePresets = []; let frameworkName = typeof framework === 'string' ? framework : framework?.name; @@ -146,7 +157,6 @@ export async function buildDevStandalone( } catch (e) {} const server = await getServer(options); - const channel = getServerChannel(server, getWsToken()); // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account @@ -158,10 +168,30 @@ export async function buildDevStandalone( ], ...options, isCritical: true, - channel, + channel: new Channel({ + transports: [ + { + setHandler: () => () => console.error('CHANNEL IS NOT READY YET'), + send: () => () => console.error('CHANNEL IS NOT READY YET'), + }, + ], + }), }); - const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); + const { allowedHosts, renderer, builder, disableTelemetry } = await presets.apply('core', {}); + + // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. + // By default we allow requests from all hosts in this case, but the user should be made aware of the risk. + if ( + options.host === '0.0.0.0' && + (!allowedHosts || (allowedHosts !== true && allowedHosts.length === 0)) + ) { + logger.warn(dedent` + --host is set to 0.0.0.0 but no allowedHosts are defined. Allowing all hosts. + To restrict allowed hosts, set core.allowedHosts in your main Storybook config. + See: https://storybook.js.org/docs/api/main-config/main-config-core + `); + } if (!builder) { throw new MissingBuilderError(); @@ -202,6 +232,14 @@ export async function buildDevStandalone( const resolvedRenderer = renderer && resolveAddonName(options.configDir, renderer, options); + const channel = getServerChannel(server, { + token: getWsToken(), + host: options.host, + allowedHosts, + localAddress, + networkAddress, + }); + // Load second pass: all presets are applied in order presets = await loadAllPresets({ corePresets: [ @@ -230,7 +268,7 @@ export async function buildDevStandalone( channel, }; - const { address, networkAddress, managerResult, previewResult } = await buildOrThrow(async () => + const { managerResult, previewResult } = await buildOrThrow(async () => storybookDevServer(fullOptions, server) ); @@ -280,12 +318,13 @@ export async function buildDevStandalone( updateInfo: versionCheck, version: storybookVersion, name, - address, + address: localAddress, networkAddress, + allowedHosts, managerTotalTime, previewTotalTime, }); } } - return { port, address, networkAddress }; + return { port, address: localAddress, networkAddress }; } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index accc93fdcca6..8fa66c62499f 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -5,7 +5,6 @@ import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; -import invariant from 'tiny-invariant'; import { telemetry } from '../telemetry'; import { type StoryIndexGenerator } from './utils/StoryIndexGenerator'; @@ -13,12 +12,12 @@ import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; +import { getHostValidationMiddleware } from './utils/getHostValidationMiddleware'; import { registerIndexJsonRoute } from './utils/index-json'; import { registerManifests } from './utils/manifests/manifests'; import { useStorybookMetadata } from './utils/metadata'; import { getMiddleware } from './utils/middleware'; import { openInBrowser } from './utils/open-browser/open-in-browser'; -import { getServerAddresses } from './utils/server-address'; import type { getServer } from './utils/server-init'; import { useStatics } from './utils/server-statics'; import { summarizeIndex } from './utils/summarizeIndex'; @@ -50,6 +49,14 @@ export async function storybookDevServer( options.extendServer(server); } + app.use( + getHostValidationMiddleware({ + host: options.host, + allowedHosts: core?.allowedHosts, + localAddress: options.localAddress, + networkAddress: options.networkAddress, + }) + ); app.use(getAccessControlMiddleware(core?.crossOriginIsolated ?? false)); app.use(getCachingMiddleware()); @@ -67,14 +74,6 @@ export async function storybookDevServer( // Apply experimental_devServer preset to allow addons/frameworks to extend the dev server with middlewares, etc. await options.presets.apply('experimental_devServer', app); - const { port, host, initialPath } = options; - invariant(port, 'expected options to have a port'); - const proto = options.https ? 'https' : 'http'; - const { address, networkAddress } = getServerAddresses(port, host, proto, initialPath); - - // Expose addresses on options for the manager builder to surface in globals, important for QR code link sharing - options.networkAddress = networkAddress; - if (!core?.builder) { throw new MissingBuilderError(); } @@ -139,15 +138,15 @@ export async function storybookDevServer( const listening = new Promise((resolve, reject) => { server.once('error', reject); - app.listen({ port, host }, resolve); + app.listen({ port: options.port, host: options.host }, resolve); }); try { const [indexGenerator] = await Promise.all([storyIndexGeneratorPromise, listening]); if (indexGenerator && !options.ci && !options.smokeTest && options.open) { - const url = host ? networkAddress : address; - openInBrowser(options.previewOnly ? `${url}iframe.html?navigator=true` : url).catch(() => { + const url = options.host ? options.networkAddress : options.localAddress; + openInBrowser(options.previewOnly ? `${url}iframe.html?navigator=true` : url!).catch(() => { // the browser window could not be opened, this is non-critical, we just ignore the error }); } @@ -186,5 +185,5 @@ export async function storybookDevServer( process.on('SIGTERM', cancelTelemetry); } - return { previewResult, managerResult, address, networkAddress }; + return { previewResult, managerResult }; } diff --git a/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts new file mode 100644 index 000000000000..5df097be2e49 --- /dev/null +++ b/code/core/src/core-server/utils/__tests__/getHostValidationMiddleware.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getHostValidationMiddleware, isValidHost } from '../getHostValidationMiddleware'; + +function createMockRequest(headers: Record) { + return { headers } as any; +} + +function createMockResponse() { + const res: any = { + writeHead: vi.fn(), + end: vi.fn(), + }; + return res; +} + +describe('getHostValidationMiddleware', () => { + it('calls next() when allowedHosts is true (allow all)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: true, + }); + const req = createMockRequest({ host: 'malicious-site.com:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('returns 403 when Host is invalid (strict allowedHosts)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: [], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'evil.com:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' }); + expect(res.end).toHaveBeenCalledWith('Invalid host'); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when Host is valid (matches localAddress)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: [], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'localhost:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('calls next() when Host matches networkAddress', () => { + const middleware = getHostValidationMiddleware({ + host: '0.0.0.0', + allowedHosts: [], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: '192.168.1.100:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('calls next() when allowedHosts is empty but Host matches localAddress', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: [], + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + }); + const req = createMockRequest({ host: 'localhost:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('returns 403 when Host header is absent (allowedHosts not true)', () => { + const middleware = getHostValidationMiddleware({ + host: 'localhost', + allowedHosts: [], + localAddress: 'http://localhost:6006', + }); + const req = createMockRequest({}); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when Host matches allowedHosts hostname (custom domain)', () => { + const middleware = getHostValidationMiddleware({ + allowedHosts: ['my-app.example.com'], + }); + const req = createMockRequest({ host: 'my-app.example.com:6006' }); + const res = createMockResponse(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalled(); + }); +}); + +describe('isValidHost', () => { + const options = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + } as any; + + /** When allowedHosts is set to [], only local/network hosts are allowed (no default allow-all). */ + const strictOptions = { ...options, allowedHosts: [] } as any; + + it('returns true for exact local address match', () => { + expect(isValidHost('localhost:6006', options)).toBe(true); + }); + + it('returns true for network address match', () => { + expect(isValidHost('192.168.1.100:6006', options)).toBe(true); + }); + + it('returns true for localhost host', () => { + expect(isValidHost('localhost:6006', options)).toBe(true); + }); + + it('returns true for 127.0.0.1 host', () => { + expect(isValidHost('127.0.0.1:6006', options)).toBe(true); + }); + + // TODO: Change default to [] in SB11 + it('when allowedHosts is undefined (default true), allows any host', () => { + expect(isValidHost('malicious-site.com', options)).toBe(true); + expect(isValidHost('any-origin.example.com', options)).toBe(true); + }); + + it('when allowedHosts is true, allows any host', () => { + const allowAllOptions = { ...options, allowedHosts: true } as any; + expect(isValidHost('malicious-site.com', allowAllOptions)).toBe(true); + expect(isValidHost('any-origin.example.com', allowAllOptions)).toBe(true); + }); + + it('returns false for different host when allowedHosts is empty', () => { + expect(isValidHost('malicious-site.com', strictOptions)).toBe(false); + }); + + it('returns true for localhost with different port (host-validation-middleware allows localhost)', () => { + expect(isValidHost('localhost:8080', strictOptions)).toBe(true); + }); + + it('returns true for localhost with same host (host-validation-middleware allows localhost)', () => { + expect(isValidHost('localhost:6006', strictOptions)).toBe(true); + }); + + it('returns false for undefined host when not allow-all', () => { + expect(isValidHost(undefined, strictOptions)).toBe(false); + }); + + it('returns false for null host when not allow-all', () => { + expect(isValidHost(null as any, strictOptions)).toBe(false); + }); + + it('returns true when localAddress is missing but hostname is localhost (host-validation-middleware allows localhost)', () => { + const optionsWithoutLocal = { + networkAddress: 'http://192.168.1.100:6006', + allowedHosts: [], + } as any; + expect(isValidHost('localhost:6006', optionsWithoutLocal)).toBe(true); + }); + + it('handles https local/network addresses correctly', () => { + const httpsOptions = { + localAddress: 'https://localhost:6006', + networkAddress: 'https://192.168.1.100:6006', + } as any; + expect(isValidHost('localhost:6006', httpsOptions)).toBe(true); + expect(isValidHost('192.168.1.100:6006', httpsOptions)).toBe(true); + const strictHttpsOptions = { ...httpsOptions, allowedHosts: [] } as any; + expect(isValidHost('localhost:6006', strictHttpsOptions)).toBe(true); + }); + + it('handles network address without port', () => { + const optionsWithoutNetwork = { + localAddress: 'http://localhost:6006', + } as any; + expect(isValidHost('localhost:6006', optionsWithoutNetwork)).toBe(true); + const strictNoNetwork = { ...optionsWithoutNetwork, allowedHosts: [] } as any; + expect(isValidHost('192.168.1.100:6006', strictNoNetwork)).toBe(true); + }); + + it('returns true for different network IP (host-validation-middleware allows any IPv4)', () => { + expect(isValidHost('10.0.0.1:6006', strictOptions)).toBe(true); + }); + + it('returns true when request host is in allowedHosts', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com', 'other.example.com'], + } as any; + expect(isValidHost('my-app.example.com', optionsWithAllowed)).toBe(true); + expect(isValidHost('other.example.com', optionsWithAllowed)).toBe(true); + }); + + it('returns false when request host is not in allowedHosts and not local/network', () => { + const optionsWithAllowed = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('other-site.com', optionsWithAllowed)).toBe(false); + }); + + it('allowedHosts does not override default local/network checks', () => { + const optionsWithAllowed = { + ...options, + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('localhost:6006', optionsWithAllowed)).toBe(true); + expect(isValidHost('192.168.1.100:6006', optionsWithAllowed)).toBe(true); + }); + + it('empty allowedHosts does not allow arbitrary hosts', () => { + const optionsWithEmptyAllowed = { + localAddress: 'http://localhost:6006', + allowedHosts: [], + } as any; + expect(isValidHost('localhost:6006', optionsWithEmptyAllowed)).toBe(true); + expect(isValidHost('arbitrary.com', optionsWithEmptyAllowed)).toBe(false); + }); + + it('when host is 0.0.0.0 and allowedHosts is empty, permits all hosts', () => { + const optionsZeroHost = { + host: '0.0.0.0', + allowedHosts: [], + } as any; + expect(isValidHost('malicious-site.com', optionsZeroHost)).toBe(true); + expect(isValidHost('any-origin.example.com', optionsZeroHost)).toBe(true); + }); + + it('when host is 0.0.0.0 but allowedHosts is not empty, does not permit arbitrary hosts', () => { + const optionsZeroHostWithAllowed = { + host: '0.0.0.0', + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com'], + } as any; + expect(isValidHost('malicious-site.com', optionsZeroHostWithAllowed)).toBe(false); + expect(isValidHost('my-app.example.com', optionsZeroHostWithAllowed)).toBe(true); + }); + + it('when allowedHosts has hostnames, matches by hostname (host-validation-middleware ignores port in allowedHosts)', () => { + const optionsWithAllowed = { + localAddress: 'http://localhost:6006', + allowedHosts: ['my-app.example.com', 'other.example.com'], + } as any; + expect(isValidHost('my-app.example.com:8443', optionsWithAllowed)).toBe(true); + expect(isValidHost('other.example.com:8080', optionsWithAllowed)).toBe(true); + expect(isValidHost('other-site.com:8080', optionsWithAllowed)).toBe(false); + }); +}); 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 db7b73ba1de0..9128df924339 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 @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel } from 'storybook/internal/channels'; @@ -8,27 +8,41 @@ import { stringify } from 'telejson'; import { ServerChannelTransport, getServerChannel } from '../get-server-channel'; +const mockToken = 'test-token-123'; + +const options = { + localAddress: 'http://localhost:6006', + networkAddress: 'http://192.168.1.100:6006', + token: mockToken, +} as any; + describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; - const result = getServerChannel(server, 'test-token-123'); + const result = getServerChannel(server, options); expect(result).toBeInstanceOf(Channel); }); it('should attach to the http server', () => { const server = { on: vi.fn() } as any as Server; - getServerChannel(server, 'test-token-123'); + getServerChannel(server, options); expect(server.on).toHaveBeenCalledWith('upgrade', expect.any(Function)); }); }); describe('ServerChannelTransport', () => { - const mockToken = 'test-token-123'; + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); it('parses simple JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -42,7 +56,7 @@ describe('ServerChannelTransport', () => { it('parses object JSON', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -56,7 +70,7 @@ describe('ServerChannelTransport', () => { it('supports telejson cyclical data', () => { const server = new EventEmitter() as any as Server; const socket = new EventEmitter(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options); const handler = vi.fn(); transport.setHandler(handler); @@ -75,17 +89,45 @@ 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, options); + + // Simulate upgrade request without token + const request = { + url: '/storybook-server-channel', + headers: { + origin: 'http://localhost:6006', + }, + } 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'); - new ServerChannelTransport(server, mockToken); + new ServerChannelTransport(server, options); // Simulate upgrade request with wrong token const request = { url: '/storybook-server-channel?token=wrong-token', + headers: { + origin: 'http://localhost:6006', + }, } as any; const head = Buffer.from(''); @@ -104,15 +146,18 @@ describe('ServerChannelTransport', () => { socket.destroy = vi.fn(); const destroySpy = vi.spyOn(socket, 'destroy'); const handleUpgradeSpy = vi.fn(); - const transport = new ServerChannelTransport(server, mockToken); + const transport = new ServerChannelTransport(server, options); // Mock handleUpgrade to track if it's called // @ts-expect-error (accessing private property) transport.socket.handleUpgrade = handleUpgradeSpy; - // Simulate upgrade request with correct token + // Simulate upgrade request with correct token and valid origin const request = { url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://localhost:6006', + }, } as any; const head = Buffer.from(''); @@ -122,4 +167,140 @@ describe('ServerChannelTransport', () => { expect(destroySpy).not.toHaveBeenCalled(); expect(handleUpgradeSpy).toHaveBeenCalled(); }); + + it('rejects connections with invalid origin', () => { + 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, options); + + // Simulate upgrade request with invalid origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://malicious-site.com', + }, + } 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 without origin header', () => { + 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, options); + + // Simulate upgrade request without origin header + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: {}, + } 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 network address origin', () => { + 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, options); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request with network address origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://192.168.1.100:6006', + }, + } 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(); + }); + + it('accepts connections with 127.0.0.1 origin', () => { + 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, options); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request with 127.0.0.1 origin + const request = { + url: `/storybook-server-channel?token=${mockToken}`, + headers: { + origin: 'http://127.0.0.1:6006', + }, + } 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(); + }); + + it('rejects connections to wrong path', () => { + 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, options); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + // Simulate upgrade request to wrong path + const request = { + url: `/wrong-path?token=${mockToken}`, + headers: { + origin: 'http://localhost:6006', + }, + } as any; + const head = Buffer.from(''); + + server.listeners('upgrade')[0](request, socket, head); + + // Should not call handleUpgrade for wrong path + expect(handleUpgradeSpy).not.toHaveBeenCalled(); + // Socket should not be destroyed for wrong path (just ignored) + expect(destroySpy).not.toHaveBeenCalled(); + }); }); diff --git a/code/core/src/core-server/utils/__tests__/validate-token.test.ts b/code/core/src/core-server/utils/__tests__/validate-token.test.ts new file mode 100644 index 000000000000..c377d781e48f --- /dev/null +++ b/code/core/src/core-server/utils/__tests__/validate-token.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { isValidToken } from '../validate-token'; + +describe('isValidToken', () => { + const validToken = 'test-token-123'; + + it('returns true for matching tokens', () => { + expect(isValidToken(validToken, validToken)).toBe(true); + }); + + it('returns false for non-matching tokens', () => { + expect(isValidToken('wrong-token', validToken)).toBe(false); + }); + + it('returns false for null token', () => { + expect(isValidToken(null, validToken)).toBe(false); + }); + + it('returns false for undefined token', () => { + expect(isValidToken(undefined as any, validToken)).toBe(false); + }); + + it('returns false for empty token', () => { + expect(isValidToken('', validToken)).toBe(false); + }); + + it('returns false when expected token is null', () => { + expect(isValidToken(validToken, null as any)).toBe(false); + }); + + it('returns false when expected token is undefined', () => { + expect(isValidToken(validToken, undefined as any)).toBe(false); + }); + + it('returns false for empty expected token', () => { + expect(isValidToken(validToken, '')).toBe(false); + }); + + it('returns false for tokens with different lengths', () => { + expect(isValidToken('short', 'much-longer-token')).toBe(false); + }); + + it('handles special characters in tokens', () => { + const specialToken = 'token-with-special-chars-!@#$%^&*()'; + expect(isValidToken(specialToken, specialToken)).toBe(true); + expect(isValidToken(specialToken, 'different-token')).toBe(false); + }); + + it('handles unicode characters in tokens', () => { + const unicodeToken = 'token-with-unicode-πŸš€-ζ΅‹θ―•'; + expect(isValidToken(unicodeToken, unicodeToken)).toBe(true); + expect(isValidToken(unicodeToken, 'different-token')).toBe(false); + }); +}); 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 5ff31bcb296e..2050bf9d71e7 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -6,11 +6,17 @@ import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; +import { logger } from '../../node-logger'; import { UniversalStore } from '../../shared/universal-store'; -import { isValidToken } from './validate-websocket-token'; +import { type HostValidationOptions, isValidHost } from './getHostValidationMiddleware'; +import { isValidToken } from './validate-token'; type Server = NonNullable[0]>['server']>; +type ServerChannelTransportOptions = HostValidationOptions & { + token: string; +}; + /** * This class represents a channel transport that allows for a one-to-many relationship between the * server and clients. Unlike other channels such as the postmessage and websocket channel @@ -22,29 +28,36 @@ export class ServerChannelTransport { private handler?: ChannelHandler; - private token: string; - - constructor(server: Server, token: string) { - this.token = token; + constructor(server: Server, options: ServerChannelTransportOptions) { this.socket = new WebSocketServer({ noServer: true }); 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); - }); + try { + const url = request.url && new URL(request.url, options.localAddress); + if (!url || url.pathname !== '/storybook-server-channel') { + return; + } + + const originHost = request.headers.origin && new URL(request.headers.origin).host; + if (!isValidHost(originHost, options)) { + throw new Error('Invalid websocket origin'); } + + const requestToken = url.searchParams.get('token'); + if (!isValidToken(requestToken, options.token)) { + throw new Error('Invalid websocket token'); + } + + this.socket.handleUpgrade(request, socket, head, (ws) => { + this.socket.emit('connection', ws, request); + }); + } catch (error) { + logger.warn(`Rejecting WebSocket connection: ${error}`); + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); } }); + this.socket.on('connection', (wss) => { wss.on('message', (raw) => { const data = raw.toString(); @@ -84,8 +97,8 @@ export class ServerChannelTransport { } } -export function getServerChannel(server: Server, token: string) { - const transports = [new ServerChannelTransport(server, token)]; +export function getServerChannel(server: Server, options: ServerChannelTransportOptions) { + const transports = [new ServerChannelTransport(server, options)]; const channel = new Channel({ transports, async: true }); diff --git a/code/core/src/core-server/utils/getHostValidationMiddleware.ts b/code/core/src/core-server/utils/getHostValidationMiddleware.ts new file mode 100644 index 000000000000..d918627ff6b6 --- /dev/null +++ b/code/core/src/core-server/utils/getHostValidationMiddleware.ts @@ -0,0 +1,68 @@ +import type { IncomingMessage } from 'node:http'; + +import { isHostAllowed } from 'host-validation-middleware'; + +import type { Middleware } from '../../types'; + +// TODO: Change to `[]` in SB11 to change from opt-in to opt-out +export const DEFAULT_ALLOWED_HOSTS: string[] | true = true; + +export type HostValidationOptions = { + host?: string; + allowedHosts?: string[] | true; + localAddress?: string; + networkAddress?: string; +}; + +/** + * Validates a host (Host header–shaped string, e.g. "localhost:6006") against known local/network + * addresses and allowed hosts. Callers must pass host-shaped input; normalize from an origin URL + * (e.g. new URL(origin).host) before invoking if needed. + * + * @param host - The host to validate (hostname or "hostname:port"). + * @param options - The builder options. + * @returns `true` if the host is valid, `false` otherwise. + */ +export const isValidHost = (host: string | undefined, options: HostValidationOptions): boolean => { + const allowedHosts = options.allowedHosts ?? DEFAULT_ALLOWED_HOSTS; + if (allowedHosts === true) { + return true; + } + if (!host) { + return false; + } + + try { + // Setting host to 0.0.0.0 binds to all hosts, which implies allowing all hosts. + // This is common in containerized environments like Docker. + if (options.host === '0.0.0.0' && allowedHosts.length === 0) { + return true; + } + return isHostAllowed(host, [ + ...allowedHosts, + ...(options.localAddress ? [new URL(options.localAddress).host] : []), + ...(options.networkAddress ? [new URL(options.networkAddress).host] : []), + ]); + } catch { + return false; + } +}; + +/** + * Validates the Host header against known local/network addresses and allowed hosts. Requests with + * no Host header (e.g. same-origin navigation, GET from address bar) are not allowed. + */ +export function getHostValidationMiddleware( + options: HostValidationOptions +): Middleware { + return (req, res, next) => { + const host = req.headers.host; + const allowedHosts = options.allowedHosts ?? DEFAULT_ALLOWED_HOSTS; + if (allowedHosts !== true && !isValidHost(host, options)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Invalid host'); + return; + } + next(); + }; +} diff --git a/code/core/src/core-server/utils/output-startup-information.ts b/code/core/src/core-server/utils/output-startup-information.ts index f86cf5d050cd..24c80bb1f42b 100644 --- a/code/core/src/core-server/utils/output-startup-information.ts +++ b/code/core/src/core-server/utils/output-startup-information.ts @@ -13,23 +13,39 @@ export function outputStartupInformation(options: { name: string; address: string; networkAddress: string; + allowedHosts?: string[] | true; managerTotalTime?: [number, number]; previewTotalTime?: [number, number]; }) { - const { updateInfo, version, name, address, networkAddress, managerTotalTime, previewTotalTime } = - options; + const { + updateInfo, + version, + name, + address, + networkAddress, + allowedHosts, + managerTotalTime, + previewTotalTime, + } = options; + const otherAllowedHosts = + allowedHosts === true + ? 'all (insecure)' + : allowedHosts?.length + ? allowedHosts.join(', ') + : undefined; const updateMessage = createUpdateMessage(updateInfo, version); const serverMessages = [ - `- Local: ${address}`, - `- On your network: ${networkAddress}`, + `- Local: ${address}`, + `- On your network: ${networkAddress}`, + otherAllowedHosts && `- Other allowed hosts: ${otherAllowedHosts}`, ]; logger.logBox( dedent` Storybook ready! - + ${serverMessages.join('\n')}${updateMessage ? `\n\n${updateMessage}` : ''} `, { diff --git a/code/core/src/core-server/utils/server-init.ts b/code/core/src/core-server/utils/server-init.ts index 6dc78f1422ef..fcd17f4cab1d 100644 --- a/code/core/src/core-server/utils/server-init.ts +++ b/code/core/src/core-server/utils/server-init.ts @@ -10,7 +10,7 @@ export async function getServer(options: { sslCert?: string; sslKey?: string; sslCa?: string[]; -}) { +}): Promise { if (!options.https) { return http.createServer(); } diff --git a/code/core/src/core-server/utils/validate-token.ts b/code/core/src/core-server/utils/validate-token.ts new file mode 100644 index 000000000000..7bd26ce91669 --- /dev/null +++ b/code/core/src/core-server/utils/validate-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(requestToken: string | null, expectedToken: string): boolean { + if (!requestToken || !expectedToken) { + return false; + } + + const a = new Uint8Array(Buffer.from(requestToken, 'utf8')); + const b = new Uint8Array(Buffer.from(expectedToken, 'utf8')); + try { + return a.length === b.length && timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/code/core/src/core-server/utils/validate-websocket-token.ts b/code/core/src/core-server/utils/validate-websocket-token.ts deleted file mode 100644 index fc5c29b802b8..000000000000 --- a/code/core/src/core-server/utils/validate-websocket-token.ts +++ /dev/null @@ -1,21 +0,0 @@ -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; - } -} diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index f702579aeecd..191989b1751b 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -293,6 +293,23 @@ describe('ConfigFile', () => { ) ).toEqual(['test', 'vitest', '!a11ytest']); }); + it('parses correctly with .type() chaining on export default', () => { + const source = dedent` + import { definePreview } from '@storybook/react-vite'; + + export default definePreview({ + parameters: { + foo: 'bar', + }, + }).type<{ + parameters: { + customParam?: string; + }; + }>(); + `; + const config = loadConfig(source).parse(); + expect(config.getFieldValue(['parameters', 'foo'])).toEqual('bar'); + }); }); }); diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index c5f376f5096f..6e326004c82d 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -204,9 +204,19 @@ export class ConfigFile { self.hasDefaultExport = true; let decl = self._resolveDeclaration(node.declaration as t.Node, parent); - // csf factory - if (t.isCallExpression(decl) && t.isObjectExpression(decl.arguments[0])) { - decl = decl.arguments[0]; + // csf factory - unwrap call expressions like definePreview({...}) or definePreview({...}).type() + while (t.isCallExpression(decl)) { + if (t.isObjectExpression(decl.arguments[0])) { + decl = decl.arguments[0]; + break; + } else if ( + t.isMemberExpression(decl.callee) && + t.isCallExpression(decl.callee.object) + ) { + decl = decl.callee.object; + } else { + break; + } } if (t.isObjectExpression(decl)) { diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 157817fc7909..30a9b769eef9 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -705,9 +705,9 @@ export const init: ModuleFn = ({ await api.setIndex(index); const refs = await fullAPI.getRefs(); - Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { - fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); - }); + for (const [refId, { internal_index, ...ref }] of Object.entries(refs)) { + await fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); + } provider.channel?.emit(SET_FILTER, { id }); }, diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index b7f37e179e59..e46494a34cf4 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -254,7 +254,8 @@ export const init: ModuleFn = (moduleArgs) => { base === 'origin' ? originAddress : base === 'network' ? networkAddress : pathname; const previewBase = refId ? refs[refId].url + '/iframe.html' - : global.PREVIEW_URL || `${managerBase.replace(/\/[^/]*$/, '/')}iframe.html`; + : global.PREVIEW_URL || + `${managerBase.replace(/\/[^/]*\.html$/, '').replace(/\/?$/, '/')}iframe.html`; const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; const { args = '', globals = '', ...otherParams } = queryParams; diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index aedbc591d13e..4b259bdf6599 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -486,4 +486,49 @@ describe('getStoryHrefs', () => { expect(managerHref).toEqual('/index.html?path=/story/test--story'); expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); }); + + it('correctly links when hosted at a subpath without trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with index.html', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/index.html', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/index.html?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); }); diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index 75a5eec411ca..28c7d8d4d042 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import type { StoryContext } from '@storybook/react-vite'; -import { fn, screen, within } from 'storybook/test'; +import { expect, fireEvent, fn, screen, waitFor, within } from 'storybook/test'; import preview from '../../../../../../.storybook/preview'; import { Zoom } from './zoom'; @@ -20,6 +20,7 @@ const meta = preview.meta({ zoomIn: fn(), zoomOut: fn(), zoomTo: fn(), + zoomBy: fn(), }, render: (args: Parameters[0]) => { const [value, setValue] = useState(args.value); @@ -30,6 +31,7 @@ const meta = preview.meta({ zoomIn: () => setValue(value + 0.5), zoomOut: () => setValue(value - 0.5), zoomTo: setValue, + zoomBy: (delta: number) => setValue(Math.max(0.01, value + delta)), }} /> ); @@ -80,3 +82,81 @@ export const MinZoom = meta.story({ value: 0.25, }, }); + +export const ArrowUpKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[ArrowUp]'); + expect(zoom).toHaveTextContent('101%'); + }, +}); + +export const ArrowDownKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + await zoom.focus(); + await userEvent.keyboard('[ArrowDown]'); + expect(zoom).toHaveTextContent('99%'); + }, +}); + +export const PageUpKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[PageUp]'); + expect(zoom).toHaveTextContent('150%'); + }, +}); + +export const PageDownKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[PageDown]'); + expect(zoom).toHaveTextContent('50%'); + }, +}); + +export const HomeKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[Home]'); + expect(zoom).toHaveTextContent('800%'); + }, +}); + +export const EndKey = meta.story({ + args: {}, + play: async ({ canvas, userEvent }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + zoom.focus(); + await userEvent.keyboard('[End]'); + expect(zoom).toHaveTextContent('25%'); + }, +}); + +export const WheelUp = meta.story({ + args: {}, + play: async ({ canvas }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + await fireEvent.wheel(zoom, { deltaY: -100 }); + await waitFor(() => expect(zoom).toHaveTextContent('150%')); + }, +}); + +export const WheelDown = meta.story({ + args: {}, + play: async ({ canvas }) => { + const zoom = await canvas.findByRole('switch', { name: 'Change zoom level' }); + await fireEvent.wheel(zoom, { deltaY: 100 }); + await waitFor(() => expect(zoom).toHaveTextContent('50%')); + }, +}); diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 8ae440d7f092..c0aa4a88f16a 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -19,6 +19,12 @@ const ZoomButton = styled(ToggleButton)({ minWidth: 48, }); +const ZoomResetButton = styled(ActionList.Button)<{ $isInitialValue: boolean }>( + ({ $isInitialValue }) => ({ + visibility: $isInitialValue ? 'hidden' : undefined, + }) +); + const Context = createContext({ value: INITIAL_ZOOM_LEVEL, set: (v: number) => {} }); const ZoomInput = styled(NumericInput)({ @@ -56,7 +62,8 @@ export const Zoom = memo<{ zoomIn: () => void; zoomOut: () => void; zoomTo: (value: number) => void; -}>(function Zoom({ value, zoomIn, zoomOut, zoomTo }) { + zoomBy: (delta: number) => void; +}>(function Zoom({ value, zoomIn, zoomOut, zoomTo, zoomBy }) { const inputRef = useRef(null); return ( @@ -81,15 +88,16 @@ export const Zoom = memo<{ } after={ - zoomTo(INITIAL_ZOOM_LEVEL)} ariaLabel="Reset zoom" + aria-hidden={value === INITIAL_ZOOM_LEVEL} > - + } value={`${Math.round(value * 100)}%`} minValue={1} @@ -149,6 +157,35 @@ export const Zoom = memo<{ variant="ghost" ariaLabel="Change zoom level" pressed={value !== INITIAL_ZOOM_LEVEL} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + zoomBy(-0.01); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + zoomBy(0.01); + e.preventDefault(); + } else if (e.key === 'PageDown') { + zoomOut(); + e.preventDefault(); + } else if (e.key === 'PageUp') { + zoomIn(); + e.preventDefault(); + } else if (e.key === 'Home') { + zoomTo(ZOOM_LEVELS[ZOOM_LEVELS.length - 1]); + e.preventDefault(); + } else if (e.key === 'End') { + zoomTo(ZOOM_LEVELS[0]); + e.preventDefault(); + } + }} + onWheel={(e) => { + if (e.deltaY < 0) { + zoomIn(); + } else if (e.deltaY > 0) { + zoomOut(); + } + e.preventDefault(); + }} > {Math.round(value * 100)}% @@ -176,6 +213,15 @@ const ZoomWrapper = memo<{ } }, [set, value]); + const zoomBy = useCallback( + (delta: number) => { + const min = ZOOM_LEVELS[0]; + const max = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + set(Math.max(min, Math.min(max, value + delta))); + }, + [set, value] + ); + const zoomTo = useCallback( (value: number) => { set(value); @@ -210,7 +256,7 @@ const ZoomWrapper = memo<{ }); }, [api, zoomIn, zoomOut, zoomTo]); - return ; + return ; }); export const zoomTool: Addon_BaseType = { diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index ba7192ab4e20..37ae2f2a332c 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -380,6 +380,7 @@ export default { 'jsx', 'keyframes', 'lighten', + 'srOnlyStyles', 'styled', 'themes', 'tokens', diff --git a/code/core/src/theming/global.ts b/code/core/src/theming/global.ts index ba0f089b1603..3b3d69f9d54c 100644 --- a/code/core/src/theming/global.ts +++ b/code/core/src/theming/global.ts @@ -9,6 +9,19 @@ interface Return { }; } +export const srOnlyStyles = { + position: 'absolute' as const, + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + whiteSpace: 'nowrap' as const, + clip: 'rect(0, 0, 0, 0)', + clipPath: 'inset(50%)', + border: 0, +}; + export const createReset = memoize(1)( ({ typography }: { typography: Typography }): Return => ({ body: { @@ -112,18 +125,7 @@ export const createGlobal = memoize(1)(({ borderTop: `1px solid ${color.border}`, }, - '.sb-sr-only, .sb-hidden-until-focus:not(:focus)': { - position: 'absolute', - width: 1, - height: 1, - padding: 0, - margin: -1, - overflow: 'hidden', - whiteSpace: 'nowrap', - clip: 'rect(0, 0, 0, 0)', - clipPath: 'inset(50%)', - border: 0, - }, + '.sb-sr-only, .sb-hidden-until-focus:not(:focus)': srOnlyStyles, '.sb-hidden-until-focus': { opacity: 0, diff --git a/code/core/src/theming/index.ts b/code/core/src/theming/index.ts index 7650c87b0b39..585e4fd5ff35 100644 --- a/code/core/src/theming/index.ts +++ b/code/core/src/theming/index.ts @@ -33,7 +33,7 @@ export * from './types'; export { default as createCache } from '@emotion/cache'; export { default as isPropValid } from '@emotion/is-prop-valid'; -export { createGlobal, createReset } from './global'; +export { createGlobal, createReset, srOnlyStyles } from './global'; export * from './create'; export * from './convert'; export * from './ensure'; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 433e4e6af96e..e5145c2d1efd 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -47,6 +47,11 @@ export interface CoreConfig { * @see https://storybook.js.org/telemetry */ enableCrashReports?: boolean; + /** + * Enable hostname validation, currently only for WebSocket connections. Set to `[]` to disallow + * all hosts except known local/network address, or `true` to allow all hosts. + */ + allowedHosts?: string[] | true; /** * Enable CORS headings to run document in a "secure context" see: * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements @@ -212,6 +217,7 @@ export interface BuilderOptions { versionCheck?: VersionCheck; disableWebpackDefaults?: boolean; serverChannelUrl?: string; + localAddress?: string; networkAddress?: string; } @@ -341,7 +347,7 @@ type Tag = string; export interface TagOptions { /** Visually include or exclude stories with this tag in the sidebar by default */ - defaultFilterSelection?: 'include' | 'exclude'; + defaultFilterSelection?: 'include' | 'exclude' | undefined; excludeFromSidebar: boolean; excludeFromDocsStories: boolean; } diff --git a/code/package.json b/code/package.json index 1e632e8b94b0..156de43dc5b1 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.3.0-alpha.14" } diff --git a/docs/_snippets/nextjs-headers-mock.md b/docs/_snippets/nextjs-headers-mock.md index a21d6b337185..75cc713fb9fe 100644 --- a/docs/_snippets/nextjs-headers-mock.md +++ b/docs/_snippets/nextjs-headers-mock.md @@ -1,8 +1,8 @@ ```js filename="MyForm.stories.js" renderer="react" language="js" tabTitle="CSF 3" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // Replace your-framework with nextjs or nextjs-vite -import { cookies, headers } from '@storybook/your-framework/headers'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers'; import MyForm from './my-form'; @@ -12,16 +12,22 @@ export default { export const LoggedInEurope = { async beforeEach() { - // πŸ‘‡ Set mock cookies and headers ahead of rendering + // πŸ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // πŸ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }; ``` @@ -30,10 +36,10 @@ export const LoggedInEurope = { // Replace your-framework with nextjs or nextjs-vite import type { Meta, StoryObj } from '@storybook/your-framework'; -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // πŸ‘‡ Must include the `.mock` portion of filename to have mocks typed correctly -import { cookies, headers } from '@storybook/your-framework/headers.mock'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers.mock'; import MyForm from './my-form'; @@ -46,28 +52,34 @@ type Story = StoryObj; export const LoggedInEurope: Story = { async beforeEach() { - // πŸ‘‡ Set mock cookies and headers ahead of rendering + // πŸ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // πŸ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }; ``` ```ts filename="MyForm.stories.ts" renderer="react" language="ts" tabTitle="CSF Next πŸ§ͺ" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; /* * Replace your-framework with nextjs or nextjs-vite * πŸ‘‡ Must include the `.mock` portion of filename to have mocks typed correctly */ -import { cookies, headers } from '@storybook/your-framework/headers.mock'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers.mock'; import preview from '../.storybook/preview'; @@ -79,16 +91,22 @@ const meta = preview.meta({ export const LoggedInEurope = meta.story({ async beforeEach() { - // πŸ‘‡ Set mock cookies and headers ahead of rendering + // πŸ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // πŸ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }); ``` @@ -96,10 +114,10 @@ export const LoggedInEurope = meta.story({ ```js filename="MyForm.stories.js" renderer="react" language="js" tabTitle="CSF Next πŸ§ͺ" -import { expect } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; // Replace your-framework with nextjs or nextjs-vite -import { cookies, headers } from '@storybook/your-framework/headers'; +import { cookies, headers, draftMode } from '@storybook/your-framework/headers'; import preview from '../.storybook/preview'; @@ -111,16 +129,22 @@ const meta = preview.meta({ export const LoggedInEurope = meta.story({ async beforeEach() { - // πŸ‘‡ Set mock cookies and headers ahead of rendering + // πŸ‘‡ Set mock cookies, headers and draft mode ahead of rendering cookies().set('username', 'Sol'); headers().set('timezone', 'Central European Summer Time'); + draftMode.mockReturnValue({ + isEnabled: true, + enable: fn(), + disable: fn(), + }); }, async play() { // πŸ‘‡ Assert that your component called the mocks await expect(cookies().get).toHaveBeenCalledOnce(); await expect(cookies().get).toHaveBeenCalledWith('username'); await expect(headers().get).toHaveBeenCalledOnce(); - await expect(cookies().get).toHaveBeenCalledWith('timezone'); + await expect(headers().get).toHaveBeenCalledWith('timezone'); + await expect(draftMode).toHaveBeenCalled(); }, }); ``` diff --git a/docs/addons/writing-addons.mdx b/docs/addons/writing-addons.mdx index 07cc0099e087..9fe4f23b0e4f 100644 --- a/docs/addons/writing-addons.mdx +++ b/docs/addons/writing-addons.mdx @@ -30,6 +30,10 @@ The addon built in this guide is a UI-based addon, specifically a [toolbar](./ad {/* prettier-ignore-end */} + +Addons with a UI must use the same React version as Storybook. If your component library uses a different React version, you must use addons that are built and published as standalone packages. We'll cover how to do this with the [Addon Kit](https://github.com/storybookjs/addon-kit). + + ## Setup To create your first addon, you're going to use the [Addon Kit](https://github.com/storybookjs/addon-kit), a ready-to-use template featuring all the required building blocks, dependencies and configurations to help you get started building your addon. In the Addon Kit repository, click the **Use this template** button to create a new repository based on the Addon Kit's code. diff --git a/docs/api/doc-blocks/doc-block-tableofcontents.mdx b/docs/api/doc-blocks/doc-block-tableofcontents.mdx new file mode 100644 index 000000000000..96b282affee7 --- /dev/null +++ b/docs/api/doc-blocks/doc-block-tableofcontents.mdx @@ -0,0 +1,90 @@ +--- +title: 'TableOfContents' +sidebar: + order: 14 + title: TableOfContents +--- + +The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to navigate between sections quickly. It appears as a fixed sidebar on the right side of the documentation page and is hidden on smaller screens (below 768px). + +The table of contents is enabled and configured via the `docs.toc` [parameter](../../writing-stories/parameters.mdx) rather than being added directly to MDX files. When enabled, it is automatically rendered alongside the page content by [Storybook's docs container](../doc-blocks/doc-block-meta.mdx). + + + + For a step-by-step guide on enabling and customizing the table of contents, see the [Generate a table of contents](../../writing-docs/autodocs.mdx#generate-a-table-of-contents) section in the Autodocs documentation. + + + +## Enabling the table of contents + +Enable the table of contents globally in your Storybook preview configuration: + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +You can also enable or disable it for specific components in their stories file: + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +## `toc` parameter options + +The `docs.toc` parameter accepts either `true` (to enable with defaults) or an object with the following properties: + +### `contentsSelector` + +Type: `string` + +Default: `'.sbdocs-content'` + +CSS selector for the container to search for headings. Use this if you have a custom docs page layout. + +### `disable` + +Type: `boolean` + +Default: `false` + +When `true`, it hides the table of contents for the documentation page. A hidden (empty) container is still rendered to preserve the page layout. + +### `headingSelector` + +Type: `string` + +Default: `'h3'` + +CSS selector that defines which heading levels to include in the table of contents. For example, use `'h1, h2, h3'` to include the top three heading levels. + +### `ignoreSelector` + +Type: `string` + +Default: `'.docs-story *, .skip-toc'` + +CSS selector for headings to exclude from the table of contents. By default, headings inside story blocks are excluded. To also exclude a specific heading, add the `skip-toc` class to it. + +### `title` + +Type: `string | null | ReactElement` + +Default: `'Table of contents'` (visually hidden) + +Text or element to display as the title above the table of contents. Set to `null` to render no title. When a string is provided, it is rendered as a visually hidden `

` by default; pass a non-empty string to make it visible. + +### `unsafeTocbotOptions` + +Type: `object` + +Provides additional configuration options passed directly to the underlying [`Tocbot`](https://tscanlin.github.io/tocbot/) library. These options are not guaranteed to remain available in future versions of Storybook. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} diff --git a/docs/api/doc-blocks/doc-block-title.mdx b/docs/api/doc-blocks/doc-block-title.mdx index e5ffc02a82f0..1c40d97e7499 100644 --- a/docs/api/doc-blocks/doc-block-title.mdx +++ b/docs/api/doc-blocks/doc-block-title.mdx @@ -1,7 +1,7 @@ --- title: 'Title' sidebar: - order: 14 + order: 15 title: Title --- diff --git a/docs/api/doc-blocks/doc-block-typeset.mdx b/docs/api/doc-blocks/doc-block-typeset.mdx index 7e507541f7f8..f7c49b5dabe6 100644 --- a/docs/api/doc-blocks/doc-block-typeset.mdx +++ b/docs/api/doc-blocks/doc-block-typeset.mdx @@ -1,7 +1,7 @@ --- title: 'Typeset' sidebar: - order: 15 + order: 16 title: Typeset --- diff --git a/docs/api/doc-blocks/doc-block-unstyled.mdx b/docs/api/doc-blocks/doc-block-unstyled.mdx index 4ef7db0320a2..addc5f677448 100644 --- a/docs/api/doc-blocks/doc-block-unstyled.mdx +++ b/docs/api/doc-blocks/doc-block-unstyled.mdx @@ -1,7 +1,7 @@ --- title: 'Unstyled' sidebar: - order: 16 + order: 17 title: Unstyled --- diff --git a/docs/api/doc-blocks/doc-block-useof.mdx b/docs/api/doc-blocks/doc-block-useof.mdx index beb560eb48d9..a47517330bad 100644 --- a/docs/api/doc-blocks/doc-block-useof.mdx +++ b/docs/api/doc-blocks/doc-block-useof.mdx @@ -1,7 +1,7 @@ --- title: 'useOf' sidebar: - order: 17 + order: 18 title: useOf --- diff --git a/docs/releases/index.mdx b/docs/releases/index.mdx index f371a2fd290b..8bcfc5e5c176 100644 --- a/docs/releases/index.mdx +++ b/docs/releases/index.mdx @@ -25,15 +25,18 @@ npm create storybook@next ## Supported Versions -We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly across the current major version, and in rare cases (such as for a short period immediately following a new major), to the previous major. - -For example, if the latest version is `9.2.1`: - -- We support `9.x.x` versions and release `9.2.x` patch versions -- Most fixes and new work will be released as `9.3.0-alpha.x` versions - - If the next release is a major version, it would be `10.0.0-alpha.x` -- We will backport critical security fixes to `9.1.x` or `9.0.x` -- Rarely, we may backport critical fixes to `8.6.x` as necessary +We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly based on severity: +- Latest major: Receives all security fixes +- Previous two majors: Receive security patches for **High or Critical [CVSS vulnerabilities](https://en.wikipedia.org/wiki/Common_Vulnerability_Scoring_System) only** +- Older versions: No longer recieves any patches + +For example, if the latest version is `10.2.1`: + +- We support `10.x.x` versions and release `10.2.x` patch versions +- Most fixes and new work will be released as `10.3.0-alpha.x` versions + - If the next release is a major version, it would be `11.0.0-alpha.x` +- We will backport **High or Critical** security fixes to the latest minor of `9.x.x` and `8.x.x` +- Versions `7.x.x` and older will not receive security patches For compatibility with other libraries and tools in the JavaScript ecosystem, please refer to the [compatibility tracker](https://github.com/storybookjs/storybook/issues/23279). diff --git a/docs/versions/next.json b/docs/versions/next.json index bab423c17c18..a39b20674836 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.13","info":{"plain":"- A11y: Add ScrollArea prop focusable for when it has static children - [#33876](https://github.com/storybookjs/storybook/pull/33876), thanks @Sidnioulz!\n- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf!\n- Controls: Fix Object contrast issue and tidy up code - [#33923](https://github.com/storybookjs/storybook/pull/33923), thanks @Sidnioulz!\n- HMR: Fix race conditions causing stale play functions to fire on re-rendered stories - [#33930](https://github.com/storybookjs/storybook/pull/33930), thanks @copilot-swe-agent!\n- React: Handle render identifier in manifest snippet generation - [#33940](https://github.com/storybookjs/storybook/pull/33940), thanks @kasperpeulen!\n- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000!"}} \ No newline at end of file +{"version":"10.3.0-alpha.14","info":{"plain":"- CSF-Factories: Fix ConfigFile parser false warning on `definePreview({...}).type()` export default - [#33885](https://github.com/storybookjs/storybook/pull/33885), thanks @copilot-swe-agent!\n- Core: Add host/origin validation to requests and websocket connections - [#33835](https://github.com/storybookjs/storybook/pull/33835), thanks @ghengeveld!\n- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates!\n- Core: Zoom tool refinements - Hide reset button when value is initial - [#33635](https://github.com/storybookjs/storybook/pull/33635), thanks @superLipbalm!\n- Docs: Edit JSON button is now accessible at 320x256 viewport (WCAG 2.1 Reflow test) - [#33707](https://github.com/storybookjs/storybook/pull/33707), thanks @TheSeydiCharyyev!\n- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319!\n- UI: Allow direct kb/mouse actions on zoom tool button - [#33496](https://github.com/storybookjs/storybook/pull/33496), thanks @Sidnioulz!"}} \ No newline at end of file diff --git a/docs/writing-docs/doc-blocks.mdx b/docs/writing-docs/doc-blocks.mdx index cd360b99ce0d..ea52c2ada97d 100644 --- a/docs/writing-docs/doc-blocks.mdx +++ b/docs/writing-docs/doc-blocks.mdx @@ -240,6 +240,16 @@ The `Subtitle` block can serve as a secondary heading for your docs entry. ![Screenshot of Subtitle block](../_assets/api/doc-block-title-subtitle-description.png) +### [TableOfContents](../api/doc-blocks/doc-block-tableofcontents.mdx) + + + + Accepts parameters in the namespace `parameters.docs.toc`. + + + +The `TableOfContents` block renders a table of contents for the current documentation page, allowing users to quickly navigate between sections. It appears as a fixed sidebar on the right side of the page. + ### [Title](../api/doc-blocks/doc-block-title.mdx) The `Title` block serves as the primary heading for your docs entry. It is typically used to provide the component or page name. diff --git a/yarn.lock b/yarn.lock index 487cbea3d2ff..e0c21f031788 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18828,6 +18828,13 @@ __metadata: languageName: node linkType: hard +"host-validation-middleware@npm:^0.1.2": + version: 0.1.2 + resolution: "host-validation-middleware@npm:0.1.2" + checksum: 10c0/42a36849ed4898b7af574b4e5ea57070d8fcd7b98f6c7c027cd301612a83a48bf812066ef83fbc2ba9e7a50bc5986215ec802f06b64e14988f2117fbeb6bc24c + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4, hosted-git-info@npm:^2.7.1": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -28452,6 +28459,7 @@ __metadata: get-npm-tarball-url: "npm:^2.1.0" glob: "npm:^10.5.0" globby: "npm:^14.1.0" + host-validation-middleware: "npm:^0.1.2" jiti: "npm:^2.6.1" js-yaml: "npm:^4.1.0" jsdoc-type-pratt-parser: "npm:^4.0.0"