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
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
}
Loading