Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

- Core: Sanitize inputs for save from controls - [#33868](https://github.com/storybookjs/storybook/pull/33868), thanks @valentinpalkovic!
- Telemetry: Add project age - [#33910](https://github.com/storybookjs/storybook/pull/33910), thanks @shilman!
- Webpack: Improve performance of module-mocking plugins - [#33169](https://github.com/storybookjs/storybook/pull/33169), thanks @valentinpalkovic!

## 10.2.11

- Addon-Vitest: Fix postinstall a11y installation - [#33888](https://github.com/storybookjs/storybook/pull/33888), thanks @valentinpalkovic!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const PLUGIN_NAME = 'WebpackInjectMockerRuntimePlugin';
* Storybook preview bundle, are executed.
*/
export class WebpackInjectMockerRuntimePlugin {
private cachedRuntime: string | null = null;
// We need to lazy-require HtmlWebpackPlugin because it's an optional peer dependency.
private getHtmlWebpackPlugin(compiler: Compiler): typeof HtmlWebpackPlugin | null {
try {
Expand Down Expand Up @@ -52,20 +53,23 @@ export class WebpackInjectMockerRuntimePlugin {
PLUGIN_NAME,
(data, cb) => {
try {
const runtimeScriptContent = getMockerRuntime();
const runtimeScriptContent =
this.cachedRuntime ?? (this.cachedRuntime = getMockerRuntime());
const runtimeAssetName = 'mocker-runtime-injected.js';

// Use the documented `emitAsset` method to add the pre-bundled runtime script
// to the compilation's assets. This is the standard Webpack way.
compilation.emitAsset(
runtimeAssetName,
new compiler.webpack.sources.RawSource(runtimeScriptContent)
);
if (!compilation.getAsset(runtimeAssetName)) {
compilation.emitAsset(
runtimeAssetName,
new compiler.webpack.sources.RawSource(runtimeScriptContent)
);
data.assets.js.unshift(runtimeAssetName);
}

// Prepend the name of our new asset to the list of JavaScript files.
// Prepend the name of our new asset to the list of JavaScript files, once.
// HtmlWebpackPlugin will automatically create a <script> tag for it
// and place it at the beginning of the body scripts.
data.assets.js.unshift(runtimeAssetName);
cb(null, data);
} catch (error) {
// In case of an error (e.g., file not found), pass it to Webpack's compilation.
Expand Down
43 changes: 36 additions & 7 deletions code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveExternalModule,
resolveWithExtensions,
} from 'storybook/internal/mocking-utils';
import { logger } from 'storybook/internal/node-logger';

import type { Compiler } from 'webpack';

Expand Down Expand Up @@ -49,6 +50,8 @@ const PLUGIN_NAME = 'storybook-mock-plugin';
export class WebpackMockPlugin {
private readonly options: WebpackMockPluginOptions;
private mockMap: Map<string, ResolvedMock> = new Map();
private candidateSpecifiers: Set<string> = new Set();
private lastPreviewMtime: number | undefined;

constructor(options: WebpackMockPluginOptions) {
if (!options.previewConfigPath) {
Expand All @@ -63,20 +66,29 @@ export class WebpackMockPlugin {
* @param {Compiler} compiler The Webpack compiler instance.
*/
public apply(compiler: Compiler): void {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);

// This function will be called to update the mock map before each compilation.
const updateMocks = () => {
const mTimePreviewConfig = this.getPreviewConfigMtime(compiler);
if (
this.lastPreviewMtime &&
mTimePreviewConfig &&
mTimePreviewConfig <= this.lastPreviewMtime
) {
return; // unchanged
}
const resolved = this.extractAndResolveMocks(compiler);
this.mockMap = new Map(
this.extractAndResolveMocks(compiler).flatMap((mock) => [
// first one, full path
resolved.flatMap((mock) => [
[mock.absolutePath, mock],
// second one, without the extension
[mock.absolutePath.replace(/\.[^.]+$/, ''), mock],
])
);
// divide by 2 because we add both the full path and the path without the extension
logger.info(`Mock map updated with ${this.mockMap.size / 2} mocks.`);
this.candidateSpecifiers = new Set(resolved.map((m) => m.path));
this.lastPreviewMtime = mTimePreviewConfig;

if (resolved.length > 0) {
logger.info(`Mock map updated with ${resolved.length} mocks.`);
}
};

compiler.hooks.beforeRun.tap(PLUGIN_NAME, updateMocks); // for build
Expand All @@ -85,10 +97,17 @@ export class WebpackMockPlugin {
// Apply the replacement plugin. Its callback will now use the dynamically updated mockMap.
new compiler.webpack.NormalModuleReplacementPlugin(/.*/, (resource) => {
try {
if (this.mockMap.size === 0) {
return;
}
const path = resource.request;
const importer = resource.context;

const isExternal = getIsExternal(path, importer);
// Early filter only for external specifiers. Relative/local specifiers need resolution
if (isExternal && !this.candidateSpecifiers.has(path)) {
return;
}
const absolutePath = isExternal
? resolveExternalModule(path, importer)
: resolveWithExtensions(path, importer);
Expand All @@ -115,6 +134,16 @@ export class WebpackMockPlugin {
});
}

private getPreviewConfigMtime(compiler: Compiler): number | undefined {
try {
const fs = compiler.inputFileSystem as any;
const stat = fs.statSync?.(this.options.previewConfigPath);
return stat?.mtime?.getTime?.();
} catch {
return undefined;
}
}

/**
* Reads the preview config, parses it to find all `sb.mock()` calls, and resolves their
* corresponding mock implementations.
Expand Down
36 changes: 36 additions & 0 deletions code/core/src/core-server/utils/get-new-story-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,42 @@ describe('get-new-story-file', () => {
expect(storyFileContent).not.toContain(STORYBOOK_FN_PLACEHOLDER);
});

it('should prevent XSS by escaping special characters in the component file name', async () => {
const { storyFileContent } = await getNewStoryFile(
{
componentFilePath: "src/stories/Button';alert(document.domain);var a='.tsx",
componentExportName: 'Button',
componentIsDefaultExport: true,
componentExportCount: 1,
},
{
presets: {
apply: (val: string) => {
if (val === 'framework') {
return Promise.resolve('@storybook/nextjs');
}
},
},
} as unknown as Options
);

expect(storyFileContent).toMatchInlineSnapshot(`
"import type { Meta, StoryObj } from '@storybook/nextjs';

import Buttonalert(documentDomain);varA=\\' from './Button\\';alert(document.domain);var a=\\'';

const meta = {
component: Buttonalert(documentDomain);varA=\\',
} satisfies Meta<typeof Buttonalert(documentDomain);varA=\\'>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};"
`);
});

it('should create a new story file (CSF factory)', async () => {
const configDir = join(__dirname, '.storybook');
const previewConfigPath = join(configDir, 'preview.ts');
Expand Down
7 changes: 5 additions & 2 deletions code/core/src/core-server/utils/get-new-story-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { getCsfFactoryTemplateForNewStoryFile } from './new-story-templates/csf-factory-template';
import { getJavaScriptTemplateForNewStoryFile } from './new-story-templates/javascript';
import { getTypeScriptTemplateForNewStoryFile } from './new-story-templates/typescript';
import { escapeForTemplate } from './safeString';

export async function getNewStoryFile(
{
Expand All @@ -41,7 +42,7 @@ export async function getNewStoryFile(

const base = basename(componentFilePath);
const extension = extname(componentFilePath);
const basenameWithoutExtension = base.replace(extension, '');
const basenameWithoutExtension = escapeForTemplate(base.replace(extension, ''));
const dir = dirname(componentFilePath);

const { storyFileName, isTypescript, storyFileExtension } = getStoryMetadata(componentFilePath);
Expand Down Expand Up @@ -98,7 +99,9 @@ export async function getNewStoryFile(
const storyFilePath = join(getProjectRoot(), dir);
const relPath = relative(storyFilePath, previewConfigPath);
const pathWithoutExt = relPath.replace(/\.(ts|js|mts|cts|tsx|jsx)$/, '');
previewImportPath = pathWithoutExt.startsWith('.') ? pathWithoutExt : `./${pathWithoutExt}`;
previewImportPath = escapeForTemplate(
pathWithoutExt.startsWith('.') ? pathWithoutExt : `./${pathWithoutExt}`
);
}
}

Expand Down
36 changes: 36 additions & 0 deletions code/core/src/core-server/utils/safeString.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';

import { escapeForTemplate } from './safeString';

describe('safeString', () => {
describe('escapeForTemplate', () => {
it('should escape backticks in template strings', () => {
expect(escapeForTemplate('button`s.tsx')).toMatchInlineSnapshot('"button\\`s.tsx"');
});

it('should escape dollar signs for template expressions', () => {
expect(escapeForTemplate('button$file.tsx')).toMatchInlineSnapshot('"button\\$file.tsx"');
});

it('should escape backslashes', () => {
expect(escapeForTemplate('button\\file.tsx')).toMatchInlineSnapshot('"button\\\\file.tsx"');
});

it('should escape quotes', () => {
expect(escapeForTemplate("button's.tsx")).toMatchInlineSnapshot(`"button\\'s.tsx"`);
expect(escapeForTemplate('button"s.tsx')).toMatchInlineSnapshot(`"button\\"s.tsx"`);
});

it('should handle multiple special characters', () => {
expect(escapeForTemplate('button`${file}\\path.tsx')).toMatchInlineSnapshot(
`"button\\\`\\\${file}\\\\path.tsx"`
);
});

it('should preserve normal file paths', () => {
expect(escapeForTemplate('./src/components/Button.tsx')).toMatchInlineSnapshot(
'"./src/components/Button.tsx"'
);
});
});
});
18 changes: 18 additions & 0 deletions code/core/src/core-server/utils/safeString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Escape special characters in a string for safe use within template literals in generated code.
* This escapes backticks and template expression delimiters.
*
* @example
*
* ```ts
* const fileName = "button's.tsx";
* const template = `import Button from './${escapeForTemplate(fileName)}'`;
* // Results in: import Button from './button\\'s.tsx'
* ```
*/
export function escapeForTemplate(str: string): string {
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/(['"$`])/g, '\\$&') // Then escape quotes, dollar signs, and backticks
.replace(/[\n\r]/g, '\\$&'); // Then newlines
}
86 changes: 84 additions & 2 deletions code/core/src/telemetry/anonymous-id.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { normalizeGitUrl, unhashedProjectId } from './anonymous-id';
import { executeCommandSync } from 'storybook/internal/common';

import {
getAnonymousProjectId,
getProjectSince,
normalizeGitUrl,
unhashedProjectId,
} from './anonymous-id';

vi.mock(import('storybook/internal/common'), async (actualModule) => {
const actual = await actualModule();

return {
...actual,
executeCommandSync: vi.fn(actual.executeCommandSync),
getProjectRoot: () => '/path/to/project/root',
};
});

beforeEach(() => {
vi.mocked(executeCommandSync).mockReset();
});

describe('normalizeGitUrl', () => {
it('trims off https://', () => {
Expand Down Expand Up @@ -105,3 +126,64 @@ describe('unhashedProjectId', () => {
).toBe('github.com/storybookjs/storybook.gitpath/to/storybook');
});
});

describe('getProjectSince', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

it('returns the Storybook creation date from git log output', () => {
vi.mocked(executeCommandSync).mockReturnValue(
'2025-12-11 16:24:01 +0530\n' + '2014-12-11 19:09:10 +0530'
);

expect(getProjectSince()).toEqual(new Date('2025-12-11T10:54:01.000Z'));
});

it('returns undefined if git log output is empty', async () => {
vi.mocked(executeCommandSync).mockReturnValue('');

const { getProjectSince: getProjSince } = await import('./anonymous-id');

expect(getProjSince()).toBeUndefined();
});

it('returns undefined if git log fails', async () => {
vi.mocked(executeCommandSync).mockImplementation(() => {
throw new Error('git not available');
});

const { getProjectSince: getProjSince } = await import('./anonymous-id');

expect(getProjSince()).toBeUndefined();
});
});

describe('getAnonymousProjectId', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();

vi.spyOn(process, 'cwd').mockReturnValue('/path/to/project/root');
});

it('returns hashed project id for Storybook repo when git command succeeds', async () => {
vi.mocked(executeCommandSync).mockReturnValue('git@github.com:storybookjs/storybook.git');
const result = getAnonymousProjectId();

expect(result).toMatch('061e4ee22a1f7c079849d97234b3be94d016fb1f24ba11878c41f8b48c0213bf');
});

it('returns undefined when git command fails', async () => {
const { getAnonymousProjectId: getAnonId } = await import('./anonymous-id');

vi.mocked(executeCommandSync).mockImplementation(() => {
throw new Error('git not available');
});

const result = getAnonId();

expect(result).toBeUndefined();
});
});
Loading
Loading