Skip to content

Commit 06dc38b

Browse files
fix(security): prevent command injection in SMS webhook
- Add allowlist-based action execution with SAFE_ACTION_PATTERNS - Use execFileSync with shell: false to prevent shell injection - Add Twilio signature verification (HMAC-SHA1) - Add rate limiting (30 req/min per IP) - Add body size limit (50KB) and content-type validation - Replace Math.random() with crypto.randomBytes for secure IDs - Add escapeAppleScript to prevent osascript injection - Remove dangerous action templates (notifySlack, deploy, rollback) Addresses STA-412 security audit findings.
1 parent d8f9d99 commit 06dc38b

2 files changed

Lines changed: 264 additions & 40 deletions

File tree

src/hooks/sms-action-runner.ts

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
11
/**
22
* SMS Action Runner - Executes actions based on SMS responses
33
* Bridges SMS responses to Claude Code actions
4+
*
5+
* Security: Uses allowlist-based action execution to prevent command injection
46
*/
57

68
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
79
import { join } from 'path';
810
import { homedir } from 'os';
9-
import { execSync } from 'child_process';
11+
import { execSync, execFileSync } from 'child_process';
12+
import { randomBytes } from 'crypto';
13+
14+
// Allowlist of safe action patterns
15+
const SAFE_ACTION_PATTERNS: Array<{
16+
pattern: RegExp;
17+
validate?: (match: RegExpMatchArray) => boolean;
18+
}> = [
19+
// Git/GitHub CLI commands (limited to safe operations)
20+
{ pattern: /^gh pr (view|list|status|checks) (\d+)$/ },
21+
{ pattern: /^gh pr review (\d+) --approve$/ },
22+
{ pattern: /^gh pr merge (\d+) --squash$/ },
23+
{ pattern: /^gh issue (view|list) (\d+)?$/ },
24+
25+
// NPM commands (limited to safe operations)
26+
{ pattern: /^npm run (build|test|lint|lint:fix|test:run)$/ },
27+
{ pattern: /^npm (test|run build)$/ },
28+
29+
// StackMemory commands
30+
{ pattern: /^stackmemory (status|notify check|context list)$/ },
31+
32+
// Simple echo/confirmation (no variables)
33+
{ pattern: /^echo "?(Done|OK|Confirmed|Acknowledged)"?$/ },
34+
];
35+
36+
/**
37+
* Check if an action is in the allowlist
38+
*/
39+
function isActionAllowed(action: string): boolean {
40+
const trimmed = action.trim();
41+
return SAFE_ACTION_PATTERNS.some(({ pattern, validate }) => {
42+
const match = trimmed.match(pattern);
43+
if (!match) return false;
44+
if (validate && !validate(match)) return false;
45+
return true;
46+
});
47+
}
1048

1149
export interface PendingAction {
1250
id: string;
@@ -55,7 +93,8 @@ export function queueAction(
5593
action: string
5694
): string {
5795
const queue = loadActionQueue();
58-
const id = Math.random().toString(36).substring(2, 10);
96+
// Use cryptographically secure random ID
97+
const id = randomBytes(8).toString('hex');
5998

6099
queue.actions.push({
61100
id,
@@ -70,6 +109,47 @@ export function queueAction(
70109
return id;
71110
}
72111

112+
/**
113+
* Execute an action safely using allowlist validation
114+
* This prevents command injection by only allowing pre-approved commands
115+
*/
116+
export function executeActionSafe(
117+
action: string,
118+
_response: string
119+
): { success: boolean; output?: string; error?: string } {
120+
// Check if action is in allowlist
121+
if (!isActionAllowed(action)) {
122+
console.error(`[sms-action] Action not in allowlist: ${action}`);
123+
return {
124+
success: false,
125+
error: `Action not allowed. Only pre-approved commands can be executed via SMS.`,
126+
};
127+
}
128+
129+
try {
130+
console.log(`[sms-action] Executing safe action: ${action}`);
131+
132+
// Parse the action into command and args
133+
const parts = action.split(' ');
134+
const cmd = parts[0];
135+
const args = parts.slice(1);
136+
137+
// Use execFileSync for commands without shell interpretation
138+
// This prevents shell injection even if the allowlist is somehow bypassed
139+
const output = execFileSync(cmd, args, {
140+
encoding: 'utf8',
141+
timeout: 60000,
142+
stdio: ['pipe', 'pipe', 'pipe'],
143+
shell: false, // Explicitly disable shell
144+
});
145+
146+
return { success: true, output };
147+
} catch (err) {
148+
const error = err instanceof Error ? err.message : String(err);
149+
return { success: false, error };
150+
}
151+
}
152+
73153
export function getPendingActions(): PendingAction[] {
74154
const queue = loadActionQueue();
75155
return queue.actions.filter((a) => a.status === 'pending');
@@ -167,31 +247,49 @@ export function cleanupOldActions(): number {
167247

168248
/**
169249
* Action Templates - Common actions for SMS responses
250+
*
251+
* SECURITY NOTE: These templates return command strings that must be
252+
* validated against SAFE_ACTION_PATTERNS before execution.
253+
* Templates that accept user input are removed to prevent injection.
170254
*/
171255
export const ACTION_TEMPLATES = {
172-
// Git/PR actions
173-
approvePR: (prNumber: string) =>
174-
`gh pr review ${prNumber} --approve && gh pr merge ${prNumber} --auto`,
175-
requestChanges: (prNumber: string) =>
176-
`gh pr review ${prNumber} --request-changes -b "Changes requested via SMS"`,
177-
mergePR: (prNumber: string) => `gh pr merge ${prNumber} --squash`,
178-
closePR: (prNumber: string) => `gh pr close ${prNumber}`,
179-
180-
// Deployment actions
181-
deploy: (env: string = 'production') => `npm run deploy:${env}`,
182-
rollback: (env: string = 'production') => `npm run rollback:${env}`,
183-
verifyDeployment: (url: string) => `curl -sf ${url}/health || exit 1`,
184-
185-
// Build actions
256+
// Git/PR actions (PR numbers must be validated as integers)
257+
approvePR: (prNumber: string) => {
258+
// Validate PR number is numeric only
259+
if (!/^\d+$/.test(prNumber)) {
260+
throw new Error('Invalid PR number');
261+
}
262+
return `gh pr review ${prNumber} --approve`;
263+
},
264+
mergePR: (prNumber: string) => {
265+
if (!/^\d+$/.test(prNumber)) {
266+
throw new Error('Invalid PR number');
267+
}
268+
return `gh pr merge ${prNumber} --squash`;
269+
},
270+
viewPR: (prNumber: string) => {
271+
if (!/^\d+$/.test(prNumber)) {
272+
throw new Error('Invalid PR number');
273+
}
274+
return `gh pr view ${prNumber}`;
275+
},
276+
277+
// Build actions (no user input)
186278
rebuild: () => `npm run build`,
187-
retest: () => `npm test`,
279+
retest: () => `npm run test:run`,
188280
lint: () => `npm run lint:fix`,
189281

190-
// Notification actions
191-
notifySlack: (message: string) =>
192-
`curl -X POST $SLACK_WEBHOOK -d '{"text":"${message}"}'`,
193-
notifyTeam: (message: string) =>
194-
`stackmemory notify send "${message}" --title "Team Alert"`,
282+
// Status actions (no user input)
283+
status: () => `stackmemory status`,
284+
checkNotifications: () => `stackmemory notify check`,
285+
286+
// REMOVED for security - these templates allowed arbitrary user input:
287+
// - requestChanges (allowed arbitrary message)
288+
// - closePR (could be used maliciously)
289+
// - deploy/rollback (too dangerous for SMS)
290+
// - verifyDeployment (allowed arbitrary URL)
291+
// - notifySlack (allowed arbitrary message - command injection)
292+
// - notifyTeam (allowed arbitrary message - command injection)
195293
};
196294

197295
/**

0 commit comments

Comments
 (0)