Skip to content

Commit 141e31d

Browse files
committed
fix: security hardening, chat sync bugs, and code cleanup
Security: - CORS now denies unknown origins instead of allowing all - Path traversal prevention via safePath() on all file operations - Replace new Function() with vm.runInNewContext() in config loader Chat/Sync: - API_BASE lazily evaluated to fix race condition with MDX wrapper - Non-streaming fallback now persists chat to localStorage - generateStory returns post-processed code instead of pre-fix code - Remove _debug field from streaming request body Cleanup: - Remove debug console.logs from StoryUIPanel - CLI version reads from package.json instead of hardcoded 1.0.0 - Remove 3 dead functions and unused import from CLI - Remove unused pg/types-pg dependencies - Update API key validation models (gpt-4o-mini, gemini-2.0-flash)
1 parent f419431 commit 141e31d

File tree

8 files changed

+115
-224
lines changed

8 files changed

+115
-224
lines changed

cli/index.ts

Lines changed: 5 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { spawn } from 'child_process';
55
import fs from 'fs';
66
import path from 'path';
77
import { fileURLToPath } from 'url';
8-
import { createStoryUIConfig } from '../story-ui.config.js';
98
import { setupCommand, cleanupDefaultStorybookComponents } from './setup.js';
109
import { deployCommand } from './deploy.js';
1110
import { updateCommand, statusCommand } from './update.js';
@@ -14,12 +13,16 @@ import net from 'net';
1413
const __filename = fileURLToPath(import.meta.url);
1514
const __dirname = path.dirname(__filename);
1615

16+
// Read version from package.json
17+
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
18+
const packageVersion = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')).version;
19+
1720
const program = new Command();
1821

1922
program
2023
.name('story-ui')
2124
.description('AI-powered Storybook story generator for React component libraries')
22-
.version('1.0.0');
25+
.version(packageVersion);
2326

2427
program
2528
.command('init')
@@ -156,129 +159,6 @@ program
156159
}
157160
});
158161

159-
async function autoDetectAndCreateConfig() {
160-
const cwd = process.cwd();
161-
const config: any = {
162-
generatedStoriesPath: './src/stories/generated',
163-
storyPrefix: 'Generated/',
164-
defaultAuthor: 'Story UI AI',
165-
componentPrefix: '',
166-
layoutRules: {
167-
multiColumnWrapper: 'div',
168-
columnComponent: 'div',
169-
layoutExamples: {
170-
twoColumn: `<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem'}}>
171-
<div>Column 1 content</div>
172-
<div>Column 2 content</div>
173-
</div>`
174-
},
175-
prohibitedElements: []
176-
}
177-
};
178-
179-
// Try to detect package.json
180-
const packageJsonPath = path.join(cwd, 'package.json');
181-
if (fs.existsSync(packageJsonPath)) {
182-
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
183-
config.importPath = packageJson.name || 'your-component-library';
184-
}
185-
186-
// Try to detect components directory
187-
const possibleComponentPaths = [
188-
'./src/components',
189-
'./lib/components',
190-
'./components',
191-
'./src'
192-
];
193-
194-
for (const possiblePath of possibleComponentPaths) {
195-
if (fs.existsSync(path.join(cwd, possiblePath))) {
196-
config.componentsPath = possiblePath;
197-
break;
198-
}
199-
}
200-
201-
writeConfig(config, 'js');
202-
}
203-
204-
async function createTemplateConfig(template: string) {
205-
let config: any;
206-
207-
switch (template) {
208-
case 'chakra-ui':
209-
config = {
210-
importPath: '@chakra-ui/react',
211-
componentPrefix: '',
212-
layoutRules: {
213-
multiColumnWrapper: 'SimpleGrid',
214-
columnComponent: 'Box',
215-
layoutExamples: {
216-
twoColumn: `<SimpleGrid columns={2} spacing={4}>
217-
<Box><Card>Left content</Card></Box>
218-
<Box><Card>Right content</Card></Box>
219-
</SimpleGrid>`
220-
}
221-
}
222-
};
223-
break;
224-
225-
case 'ant-design':
226-
config = {
227-
importPath: 'antd',
228-
componentPrefix: '',
229-
layoutRules: {
230-
multiColumnWrapper: 'Row',
231-
columnComponent: 'Col',
232-
layoutExamples: {
233-
twoColumn: `<Row gutter={16}>
234-
<Col span={12}><Card>Left content</Card></Col>
235-
<Col span={12}><Card>Right content</Card></Col>
236-
</Row>`
237-
}
238-
}
239-
};
240-
break;
241-
242-
default:
243-
throw new Error(`Unknown template: ${template}`);
244-
}
245-
246-
// Add common defaults
247-
config = {
248-
generatedStoriesPath: './src/stories/generated',
249-
componentsPath: './src/components',
250-
storyPrefix: 'Generated/',
251-
defaultAuthor: 'Story UI AI',
252-
...config
253-
};
254-
255-
writeConfig(config, 'js');
256-
}
257-
258-
async function createBasicConfig() {
259-
const config = {
260-
generatedStoriesPath: './src/stories/generated',
261-
componentsPath: './src/components',
262-
storyPrefix: 'Generated/',
263-
defaultAuthor: 'Story UI AI',
264-
importPath: 'your-component-library',
265-
componentPrefix: '',
266-
layoutRules: {
267-
multiColumnWrapper: 'div',
268-
columnComponent: 'div',
269-
layoutExamples: {
270-
twoColumn: `<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem'}}>
271-
<div>Column 1 content</div>
272-
<div>Column 2 content</div>
273-
</div>`
274-
},
275-
prohibitedElements: []
276-
}
277-
};
278-
279-
writeConfig(config, 'js');
280-
}
281-
282162
function generateSampleConfig(filename: string, type: 'json' | 'js') {
283163
const config = {
284164
generatedStoriesPath: './src/stories/generated',

mcp-server/index.ts

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,29 @@ function removeStoryExtension(filename: string): string {
6464
return filename;
6565
}
6666

67+
/**
68+
* Safely resolve a file path within a base directory.
69+
* Prevents path traversal attacks by ensuring the resolved path
70+
* stays within the allowed base directory.
71+
* Returns null if the path escapes the base directory.
72+
*/
73+
function safePath(baseDir: string, fileName: string): string | null {
74+
const resolvedBase = path.resolve(baseDir);
75+
const resolvedPath = path.resolve(baseDir, fileName);
76+
if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
77+
return null;
78+
}
79+
return resolvedPath;
80+
}
81+
6782
const app = express();
6883

6984
// CORS configuration
70-
// - Allow all origins for /story-ui/* routes (public API for production Storybooks)
71-
// - Restrict to localhost + configured origins for other routes
85+
// - Allow localhost, Railway, Cloudflare Pages, and custom origins
86+
// - Deny unknown origins to prevent CSRF and unauthorized access
7287
const corsOptions = {
7388
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
74-
// Allow requests with no origin (like mobile apps or curl)
89+
// Allow requests with no origin (same-origin, mobile apps, curl, server-to-server)
7590
if (!origin) return callback(null, true);
7691

7792
// Allow localhost on any port (development)
@@ -80,21 +95,26 @@ const corsOptions = {
8095
return callback(null, true);
8196
}
8297

98+
// Allow Railway deployment domains (*.up.railway.app)
99+
const railwayPattern = /^https:\/\/[a-z0-9-]+\.up\.railway\.app$/;
100+
if (railwayPattern.test(origin)) {
101+
return callback(null, true);
102+
}
103+
83104
// Allow Cloudflare Pages domains (*.pages.dev)
84105
const cloudflarePattern = /^https:\/\/[a-z0-9-]+\.pages\.dev$/;
85106
if (cloudflarePattern.test(origin)) {
86107
return callback(null, true);
87108
}
88109

89-
// Allow custom origins from environment
90-
const allowedOrigins = process.env.STORY_UI_ALLOWED_ORIGINS?.split(',') || [];
110+
// Allow custom origins from environment (comma-separated)
111+
const allowedOrigins = process.env.STORY_UI_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [];
91112
if (allowedOrigins.includes(origin)) {
92113
return callback(null, true);
93114
}
94115

95-
// For production, allow any origin to access /story-ui/* endpoints
96-
// These are public read-only endpoints for accessing generated stories
97-
callback(null, true);
116+
// Deny unknown origins
117+
callback(new Error(`Origin ${origin} not allowed by CORS`), false);
98118
},
99119
credentials: true,
100120
};
@@ -458,10 +478,13 @@ app.post('/story-ui/stories', async (req, res) => {
458478
const extension = adapter?.defaultExtension || '.stories.tsx';
459479

460480
const fileName = `${id}${extension}`;
461-
const filePath = path.join(storiesPath, fileName);
481+
const filePath = safePath(storiesPath, fileName);
482+
if (!filePath) {
483+
return res.status(400).json({ error: 'Invalid file path' });
484+
}
462485

463486
fs.writeFileSync(filePath, code, 'utf-8');
464-
console.log(`Saved story: ${filePath}`);
487+
console.log(`Saved story: ${filePath}`);
465488

466489
return res.json({
467490
success: true,
@@ -493,7 +516,10 @@ app.delete('/story-ui/stories/:id', async (req, res) => {
493516
const extension = adapter?.defaultExtension || '.stories.tsx';
494517
fileName = `${id}${extension}`;
495518
}
496-
const filePath = path.join(storiesPath, fileName);
519+
const filePath = safePath(storiesPath, fileName);
520+
if (!filePath) {
521+
return res.status(400).json({ error: 'Invalid file path' });
522+
}
497523

498524
if (fs.existsSync(filePath)) {
499525
fs.unlinkSync(filePath);
@@ -592,7 +618,12 @@ app.post('/story-ui/stories/delete-bulk', async (req, res) => {
592618
try {
593619
// Check if id already has a story extension
594620
const fileName = isStoryFile(id) ? id : `${id}${extension}`;
595-
const filePath = path.join(storiesPath, fileName);
621+
const filePath = safePath(storiesPath, fileName);
622+
623+
if (!filePath) {
624+
errors.push(id);
625+
continue;
626+
}
596627

597628
if (fs.existsSync(filePath)) {
598629
fs.unlinkSync(filePath);
@@ -603,7 +634,9 @@ app.post('/story-ui/stories/delete-bulk', async (req, res) => {
603634
const files = fs.readdirSync(storiesPath);
604635
const matchingFile = files.find(f => isStoryFile(f) && (f === id || removeStoryExtension(f) === id));
605636
if (matchingFile) {
606-
fs.unlinkSync(path.join(storiesPath, matchingFile));
637+
const matchPath = safePath(storiesPath, matchingFile);
638+
if (!matchPath) { errors.push(id); continue; }
639+
fs.unlinkSync(matchPath);
607640
deleted.push(id);
608641
console.log(`✅ Deleted: ${matchingFile}`);
609642
} else {
@@ -651,7 +684,10 @@ app.delete('/story-ui/stories', async (req, res) => {
651684
}
652685

653686
// Try exact match first
654-
let filePath = path.join(storiesPath, fileName);
687+
let filePath = safePath(storiesPath, fileName);
688+
if (!filePath) {
689+
return res.status(400).json({ success: false, error: 'Invalid file path' });
690+
}
655691
if (fs.existsSync(filePath)) {
656692
fs.unlinkSync(filePath);
657693
console.log(`✅ Deleted story: ${filePath}`);
@@ -665,7 +701,10 @@ app.delete('/story-ui/stories', async (req, res) => {
665701
const files = fs.readdirSync(storiesPath);
666702
const matchingFile = files.find(f => f.includes(`-${hash}.stories.`));
667703
if (matchingFile) {
668-
filePath = path.join(storiesPath, matchingFile);
704+
filePath = safePath(storiesPath, matchingFile);
705+
if (!filePath) {
706+
return res.status(400).json({ success: false, error: 'Invalid file path' });
707+
}
669708
fs.unlinkSync(filePath);
670709
console.log(`✅ Deleted story by hash match: ${filePath}`);
671710
return res.json({ success: true, message: 'Story deleted successfully' });
@@ -689,7 +728,9 @@ app.delete('/story-ui/stories', async (req, res) => {
689728

690729
for (const file of storyFiles) {
691730
try {
692-
fs.unlinkSync(path.join(storiesPath, file));
731+
const fp = safePath(storiesPath, file);
732+
if (!fp) continue;
733+
fs.unlinkSync(fp);
693734
deleted++;
694735
} catch (err) {
695736
console.error(`Error deleting ${file}:`, err);
@@ -742,7 +783,8 @@ app.post('/story-ui/orphan-stories', async (req, res) => {
742783

743784
// Get details for each orphan
744785
const orphanDetails = orphans.map(fileName => {
745-
const filePath = path.join(storiesPath, fileName);
786+
const filePath = safePath(storiesPath, fileName);
787+
if (!filePath) return null;
746788
const content = fs.readFileSync(filePath, 'utf-8');
747789
const stats = fs.statSync(filePath);
748790

@@ -757,13 +799,13 @@ app.post('/story-ui/orphan-stories', async (req, res) => {
757799
title,
758800
lastUpdated: stats.mtime.getTime()
759801
};
760-
});
802+
}).filter(Boolean);
761803

762-
console.log(`📋 Found ${orphans.length} orphan stories out of ${storyFiles.length} total`);
804+
console.log(`📋 Found ${orphanDetails.length} orphan stories out of ${storyFiles.length} total`);
763805

764806
return res.json({
765807
orphans: orphanDetails,
766-
count: orphans.length,
808+
count: orphanDetails.length,
767809
totalStories: storyFiles.length
768810
});
769811
} catch (error) {
@@ -806,7 +848,8 @@ app.delete('/story-ui/orphan-stories', async (req, res) => {
806848

807849
for (const fileName of orphans) {
808850
try {
809-
const filePath = path.join(storiesPath, fileName);
851+
const filePath = safePath(storiesPath, fileName);
852+
if (!filePath) { errors.push(fileName); continue; }
810853
fs.unlinkSync(filePath);
811854
deleted.push(fileName);
812855
console.log(`🗑️ Deleted orphan story: ${fileName}`);

mcp-server/routes/generateStory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1115,7 +1115,7 @@ export async function generateStoryFromPrompt(req: Request, res: Response) {
11151115
storyId,
11161116
outPath,
11171117
title: cleanTitle, // Use versioned title (e.g., "Navigation Bar v2")
1118-
story: fileContents,
1118+
story: fixedFileContents,
11191119
storage: 'file-system',
11201120
isUpdate: isActualUpdate,
11211121
validation: {

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@
7474
},
7575
"dependencies": {
7676
"@modelcontextprotocol/sdk": "^1.23.0",
77-
"@types/pg": "^8.15.6",
7877
"chalk": "^5.3.0",
7978
"commander": "^11.0.0",
8079
"cors": "^2.8.5",
@@ -84,7 +83,6 @@
8483
"http-proxy-middleware": "^3.0.0",
8584
"inquirer": "^9.2.0",
8685
"node-fetch": "^2.6.7",
87-
"pg": "^8.16.3",
8886
"typescript": "^5.8.3",
8987
"zod": "^3.22.4"
9088
},

story-generator/configLoader.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3+
import vm from 'vm';
34
import { createRequire } from 'module';
45
import { StoryUIConfig, DEFAULT_CONFIG, createStoryUIConfig, IconImportsConfig } from '../story-ui.config.js';
56

@@ -177,9 +178,8 @@ export function loadUserConfig(): StoryUIConfig {
177178
.replace(/\/\/[^\n]*/g, '') // Remove single-line comments
178179
.replace(/,(\s*[}\]])/g, '$1'); // Remove trailing commas
179180

180-
// Use Function constructor (safer than eval, runs in isolated scope)
181-
const configFn = new Function(`return ${configObj}`);
182-
userConfig = configFn();
181+
// Use vm.runInNewContext for sandboxed evaluation (no access to require, process, etc.)
182+
userConfig = vm.runInNewContext(`(${configObj})`, Object.create(null), { timeout: 1000 });
183183
} catch (parseError) {
184184
console.warn(`Failed to parse config from ${configPath}:`, parseError);
185185
throw requireError; // Re-throw original error

story-generator/llm-providers/gemini-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export class GeminiProvider extends BaseLLMProvider {
328328
async validateApiKey(apiKey: string): Promise<ValidationResult> {
329329
try {
330330
// Make a minimal API call to validate the key
331-
const url = `${this.getApiUrl('gemini-1.5-flash')}?key=${apiKey}`;
331+
const url = `${this.getApiUrl('gemini-2.0-flash')}?key=${apiKey}`;
332332
const response = await fetch(url, {
333333
method: 'POST',
334334
headers: {

0 commit comments

Comments
 (0)