-
-
Notifications
You must be signed in to change notification settings - Fork 2
docs(F001): comprehensive implementation plan for security fixes #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import { describe, test, expect, beforeEach, afterEach } from 'vitest'; | ||
| import { getStateResource, getProgressResource, getRouteResource } from '../index.js'; | ||
| import { mkdtemp, rm, writeFile } from 'fs/promises'; | ||
| import { join } from 'path'; | ||
| import { tmpdir } from 'os'; | ||
|
|
||
| describe('Resource Security Tests', () => { | ||
| let testDir: string; | ||
| let originalCwd: () => string; | ||
|
|
||
| beforeEach(async () => { | ||
| // Create temp directory for testing | ||
| testDir = await mkdtemp(join(tmpdir(), 'stackshift-test-')); | ||
|
|
||
| // Mock process.cwd() to return test directory | ||
| originalCwd = process.cwd; | ||
| process.cwd = () => testDir; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| // Restore process.cwd() | ||
| process.cwd = originalCwd; | ||
|
|
||
| // Cleanup test directory | ||
| await rm(testDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('getStateResource', () => { | ||
| test('reads valid state file', async () => { | ||
| // Create valid state file | ||
| const stateFile = join(testDir, '.stackshift-state.json'); | ||
| await writeFile( | ||
| stateFile, | ||
| JSON.stringify({ | ||
| path: 'brownfield', | ||
| currentStep: 'analyze', | ||
| completedSteps: [], | ||
| stepDetails: {}, | ||
| }) | ||
| ); | ||
|
|
||
| // Call resource handler | ||
| const result = await getStateResource(); | ||
|
|
||
| // Verify response | ||
| expect(result.contents).toHaveLength(1); | ||
| expect(result.contents[0].uri).toBe('stackshift://state'); | ||
| expect(result.contents[0].mimeType).toBe('application/json'); | ||
|
|
||
| const state = JSON.parse(result.contents[0].text); | ||
| expect(state.path).toBe('brownfield'); | ||
| }); | ||
|
|
||
| test('rejects file larger than 10MB', async () => { | ||
| // Create large file (11MB) | ||
| const stateFile = join(testDir, '.stackshift-state.json'); | ||
| const largeData = 'A'.repeat(11 * 1024 * 1024); | ||
| await writeFile(stateFile, `{"data": "${largeData}"}`); | ||
|
|
||
| // Should handle error gracefully | ||
| const result = await getStateResource(); | ||
|
|
||
| // Should return error response (not throw) | ||
| expect(result.contents[0].text).toContain('error'); | ||
| }); | ||
|
|
||
| test('strips dangerous JSON properties', async () => { | ||
| // Create state file with prototype pollution attempt | ||
| const stateFile = join(testDir, '.stackshift-state.json'); | ||
| await writeFile( | ||
| stateFile, | ||
| JSON.stringify({ | ||
| path: 'greenfield', | ||
| __proto__: { admin: true }, | ||
| constructor: { malicious: true }, | ||
| prototype: { hacked: true }, | ||
| }) | ||
| ); | ||
|
|
||
| // Call resource handler | ||
| const result = await getStateResource(); | ||
|
|
||
| // Verify dangerous properties are removed (check as own properties, not inherited) | ||
| const state = JSON.parse(result.contents[0].text); | ||
| expect(Object.prototype.hasOwnProperty.call(state, '__proto__')).toBe(false); | ||
| expect(Object.prototype.hasOwnProperty.call(state, 'constructor')).toBe(false); | ||
| expect(Object.prototype.hasOwnProperty.call(state, 'prototype')).toBe(false); | ||
| }); | ||
|
|
||
| test('returns error when state file not found', async () => { | ||
| // Don't create state file | ||
|
|
||
| // Call resource handler | ||
| const result = await getStateResource(); | ||
|
|
||
| // Should return error response | ||
| const response = JSON.parse(result.contents[0].text); | ||
| expect(response.error).toBe('No state file found'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getProgressResource', () => { | ||
| test('reads valid state file', async () => { | ||
| // Create valid state file | ||
| const stateFile = join(testDir, '.stackshift-state.json'); | ||
| await writeFile( | ||
| stateFile, | ||
| JSON.stringify({ | ||
| path: 'brownfield', | ||
| currentStep: 'analyze', | ||
| completedSteps: ['analyze'], | ||
| stepDetails: {}, | ||
| }) | ||
| ); | ||
|
|
||
| // Call resource handler | ||
| const result = await getProgressResource(); | ||
|
|
||
| // Verify response | ||
| expect(result.contents).toHaveLength(1); | ||
| expect(result.contents[0].text).toContain('StackShift Progress'); | ||
| expect(result.contents[0].text).toContain('Complete'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getRouteResource', () => { | ||
| test('reads valid state file', async () => { | ||
| // Create valid state file | ||
| const stateFile = join(testDir, '.stackshift-state.json'); | ||
| await writeFile( | ||
| stateFile, | ||
| JSON.stringify({ | ||
| path: 'greenfield', | ||
| currentStep: 'analyze', | ||
| completedSteps: [], | ||
| stepDetails: {}, | ||
| }) | ||
| ); | ||
|
|
||
| // Call resource handler | ||
| const result = await getRouteResource(); | ||
|
|
||
| // Verify response | ||
| expect(result.contents).toHaveLength(1); | ||
| expect(result.contents[0].text).toContain('Greenfield'); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { describe, test, expect, beforeEach, afterEach } from 'vitest'; | ||
| import { loadSkillFile } from '../skill-loader.js'; | ||
| import { mkdtemp, rm } from 'fs/promises'; | ||
| import { join } from 'path'; | ||
| import { tmpdir } from 'os'; | ||
|
|
||
| describe('Skill Loader Security Tests', () => { | ||
| let testDir: string; | ||
| let originalHome: string | undefined; | ||
|
|
||
| beforeEach(async () => { | ||
| // Save original HOME | ||
| originalHome = process.env.HOME; | ||
|
|
||
| // Create temp directory and set as HOME | ||
| testDir = await mkdtemp(join(tmpdir(), 'skill-security-test-')); | ||
| process.env.HOME = testDir; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| // Restore HOME | ||
| if (originalHome !== undefined) { | ||
| process.env.HOME = originalHome; | ||
| } | ||
|
|
||
| // Cleanup | ||
| await rm(testDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| test('rejects skill name with forward slash', async () => { | ||
| await expect(loadSkillFile('../etc/passwd')).rejects.toThrow( | ||
| 'Invalid skill name: cannot contain path separators' | ||
| ); | ||
| }); | ||
|
|
||
| test('rejects skill name with backslash', async () => { | ||
| await expect(loadSkillFile('..\\windows\\system32')).rejects.toThrow( | ||
| 'Invalid skill name: cannot contain path separators' | ||
| ); | ||
| }); | ||
|
|
||
| test('rejects skill name with parent directory reference', async () => { | ||
| await expect(loadSkillFile('..')).rejects.toThrow( | ||
| 'Invalid skill name: cannot contain path separators' | ||
| ); | ||
| }); | ||
|
|
||
| test('rejects skill name with special characters', async () => { | ||
| await expect(loadSkillFile('hack;rm -rf')).rejects.toThrow( | ||
| 'Invalid skill name: must be alphanumeric with hyphens/underscores only' | ||
| ); | ||
| }); | ||
|
|
||
| test('rejects skill name with spaces', async () => { | ||
| await expect(loadSkillFile('skill name')).rejects.toThrow( | ||
| 'Invalid skill name: must be alphanumeric with hyphens/underscores only' | ||
| ); | ||
| }); | ||
|
|
||
| test('accepts valid skill name with hyphens', async () => { | ||
| // This will return null (skill not found) but should not throw validation error | ||
| const result = await loadSkillFile('reverse-engineer'); | ||
| // Either returns content or null, but doesn't throw | ||
| expect(typeof result === 'string' || result === null).toBe(true); | ||
| }); | ||
|
|
||
| test('accepts valid skill name with underscores', async () => { | ||
| const result = await loadSkillFile('create_specs'); | ||
| expect(typeof result === 'string' || result === null).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,15 +7,37 @@ | |
|
|
||
| import * as fs from 'fs/promises'; | ||
| import * as path from 'path'; | ||
| import { createDefaultValidator } from './security.js'; | ||
| import { readFileSafe } from './file-utils.js'; | ||
|
|
||
| /** | ||
| * Try to load SKILL.md from multiple possible locations | ||
| */ | ||
| export async function loadSkillFile(skillName: string): Promise<string | null> { | ||
| // Validate skill name: no path separators | ||
| if (skillName.includes('/') || skillName.includes('\\') || skillName.includes('..')) { | ||
| throw new Error(`Invalid skill name: cannot contain path separators`); | ||
| } | ||
|
|
||
| // Validate skill name: whitelist regex | ||
| if (!/^[a-zA-Z0-9_-]+$/.test(skillName)) { | ||
| throw new Error(`Invalid skill name: must be alphanumeric with hyphens/underscores only`); | ||
| } | ||
|
|
||
| // Validate HOME environment variable | ||
| const homePath = process.env.HOME; | ||
| if (!homePath || homePath.includes('\0')) { | ||
| throw new Error('Invalid HOME environment variable'); | ||
| } | ||
|
Comment on lines
+28
to
+31
|
||
|
|
||
| // Create validator for HOME directory | ||
| const validator = createDefaultValidator(); | ||
| const validatedHome = validator.validateDirectory(homePath); | ||
|
|
||
| const possiblePaths = [ | ||
| // If StackShift is installed locally | ||
| // If StackShift is installed locally (use validated HOME) | ||
| path.join( | ||
| process.env.HOME || '~', | ||
| validatedHome, | ||
| '.claude/plugins/marketplaces/jschulte/stackshift/skills', | ||
| skillName, | ||
| 'SKILL.md' | ||
|
|
@@ -31,7 +53,7 @@ export async function loadSkillFile(skillName: string): Promise<string | null> { | |
|
|
||
| for (const filePath of possiblePaths) { | ||
| try { | ||
| const content = await fs.readFile(filePath, 'utf-8'); | ||
| const content = await readFileSafe(filePath); | ||
| if (content && content.length > 100) { | ||
| console.error(`✅ Loaded SKILL.md from: ${filePath}`); | ||
| return content; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type assertion change still uses
any, which bypasses type checking. The original(args as any) || {}was problematic, butargs || {} as CruiseControlArgswould be better - asserting the specific type after applying the default. This would maintain type safety while still providing the fallback.