Skip to content

Commit ae0bde1

Browse files
feat(security): Add zod schema validation + timed cleanup
- Add schemas.ts with zod validators for SMS config, action queue, auto-background - Validate configs on load, fall back to defaults on invalid data - Add 5-minute cleanup interval in webhook server for expired prompts and old actions - Prevents malformed/malicious config injection Addresses security audit recommendations.
1 parent d47a09e commit ae0bde1

5 files changed

Lines changed: 185 additions & 10 deletions

File tree

src/hooks/auto-background.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from 'fs';
77
import { join } from 'path';
88
import { homedir } from 'os';
99
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
10+
import { AutoBackgroundConfigSchema, parseConfigSafe } from './schemas.js';
1011

1112
export interface AutoBackgroundConfig {
1213
enabled: boolean;
@@ -92,7 +93,13 @@ export function loadConfig(): AutoBackgroundConfig {
9293
try {
9394
if (existsSync(CONFIG_PATH)) {
9495
const data = readFileSync(CONFIG_PATH, 'utf8');
95-
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
96+
const parsed = JSON.parse(data);
97+
return parseConfigSafe(
98+
AutoBackgroundConfigSchema,
99+
{ ...DEFAULT_CONFIG, ...parsed },
100+
DEFAULT_CONFIG,
101+
'auto-background'
102+
);
96103
}
97104
} catch {
98105
// Use defaults

src/hooks/schemas.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Zod schemas for hook configuration validation
3+
* Prevents malformed or malicious configs from being loaded
4+
*/
5+
6+
import { z } from 'zod';
7+
8+
// SMS/WhatsApp notification schemas
9+
export const PromptOptionSchema = z.object({
10+
key: z.string().max(10),
11+
label: z.string().max(200),
12+
action: z.string().max(500).optional(),
13+
});
14+
15+
export const PendingPromptSchema = z.object({
16+
id: z.string().max(32),
17+
timestamp: z
18+
.string()
19+
.datetime({ offset: true })
20+
.or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)),
21+
message: z.string().max(1000),
22+
options: z.array(PromptOptionSchema).max(10),
23+
type: z.enum(['options', 'yesno', 'freeform']),
24+
callback: z.string().max(500).optional(),
25+
expiresAt: z
26+
.string()
27+
.datetime({ offset: true })
28+
.or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)),
29+
});
30+
31+
export const NotifyOnSchema = z.object({
32+
taskComplete: z.boolean(),
33+
reviewReady: z.boolean(),
34+
error: z.boolean(),
35+
custom: z.boolean(),
36+
});
37+
38+
export const QuietHoursSchema = z.object({
39+
enabled: z.boolean(),
40+
start: z.string().regex(/^\d{2}:\d{2}$/),
41+
end: z.string().regex(/^\d{2}:\d{2}$/),
42+
});
43+
44+
export const SMSConfigSchema = z.object({
45+
enabled: z.boolean(),
46+
channel: z.enum(['whatsapp', 'sms']),
47+
accountSid: z.string().max(100).optional(),
48+
authToken: z.string().max(100).optional(),
49+
smsFromNumber: z.string().max(20).optional(),
50+
smsToNumber: z.string().max(20).optional(),
51+
whatsappFromNumber: z.string().max(30).optional(),
52+
whatsappToNumber: z.string().max(30).optional(),
53+
fromNumber: z.string().max(20).optional(),
54+
toNumber: z.string().max(20).optional(),
55+
webhookUrl: z.string().url().max(500).optional(),
56+
notifyOn: NotifyOnSchema,
57+
quietHours: QuietHoursSchema.optional(),
58+
responseTimeout: z.number().int().min(30).max(3600),
59+
pendingPrompts: z.array(PendingPromptSchema).max(100),
60+
});
61+
62+
// Action queue schemas
63+
export const PendingActionSchema = z.object({
64+
id: z.string().max(32),
65+
promptId: z.string().max(32),
66+
response: z.string().max(1000),
67+
action: z.string().max(500),
68+
timestamp: z
69+
.string()
70+
.datetime({ offset: true })
71+
.or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)),
72+
status: z.enum(['pending', 'running', 'completed', 'failed']),
73+
result: z.string().max(10000).optional(),
74+
error: z.string().max(1000).optional(),
75+
});
76+
77+
export const ActionQueueSchema = z.object({
78+
actions: z.array(PendingActionSchema).max(1000),
79+
lastChecked: z
80+
.string()
81+
.datetime({ offset: true })
82+
.or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)),
83+
});
84+
85+
// Auto-background config schema
86+
export const AutoBackgroundConfigSchema = z.object({
87+
enabled: z.boolean(),
88+
timeoutMs: z.number().int().min(1000).max(600000),
89+
alwaysBackground: z.array(z.string().max(200)).max(100),
90+
neverBackground: z.array(z.string().max(200)).max(100),
91+
verbose: z.boolean().optional(),
92+
});
93+
94+
// Type exports
95+
export type SMSConfigValidated = z.infer<typeof SMSConfigSchema>;
96+
export type ActionQueueValidated = z.infer<typeof ActionQueueSchema>;
97+
export type AutoBackgroundConfigValidated = z.infer<
98+
typeof AutoBackgroundConfigSchema
99+
>;
100+
101+
/**
102+
* Safely parse and validate config, returning default on failure
103+
*/
104+
export function parseConfigSafe<T>(
105+
schema: z.ZodSchema<T>,
106+
data: unknown,
107+
defaultValue: T,
108+
configName: string
109+
): T {
110+
const result = schema.safeParse(data);
111+
if (result.success) {
112+
return result.data;
113+
}
114+
console.error(
115+
`[hooks] Invalid ${configName} config:`,
116+
result.error.issues
117+
.map((i) => `${i.path.join('.')}: ${i.message}`)
118+
.join(', ')
119+
);
120+
return defaultValue;
121+
}

src/hooks/sms-action-runner.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { homedir } from 'os';
1111
import { execSync, execFileSync } from 'child_process';
1212
import { randomBytes } from 'crypto';
1313
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
14+
import { ActionQueueSchema, parseConfigSafe } from './schemas.js';
1415

1516
// Allowlist of safe action patterns
1617
const SAFE_ACTION_PATTERNS: Array<{
@@ -65,15 +66,26 @@ export interface ActionQueue {
6566

6667
const QUEUE_PATH = join(homedir(), '.stackmemory', 'sms-action-queue.json');
6768

69+
const DEFAULT_QUEUE: ActionQueue = {
70+
actions: [],
71+
lastChecked: new Date().toISOString(),
72+
};
73+
6874
export function loadActionQueue(): ActionQueue {
6975
try {
7076
if (existsSync(QUEUE_PATH)) {
71-
return JSON.parse(readFileSync(QUEUE_PATH, 'utf8'));
77+
const data = JSON.parse(readFileSync(QUEUE_PATH, 'utf8'));
78+
return parseConfigSafe(
79+
ActionQueueSchema,
80+
data,
81+
DEFAULT_QUEUE,
82+
'action-queue'
83+
);
7284
}
7385
} catch {
7486
// Use defaults
7587
}
76-
return { actions: [], lastChecked: new Date().toISOString() };
88+
return { ...DEFAULT_QUEUE, lastChecked: new Date().toISOString() };
7789
}
7890

7991
export function saveActionQueue(queue: ActionQueue): void {

src/hooks/sms-notify.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { join } from 'path';
1111
import { homedir } from 'os';
1212
import { config as loadDotenv } from 'dotenv';
1313
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
14+
import { SMSConfigSchema, parseConfigSafe } from './schemas.js';
1415

1516
export type MessageChannel = 'whatsapp' | 'sms';
1617

@@ -109,11 +110,16 @@ export function loadSMSConfig(): SMSConfig {
109110
try {
110111
if (existsSync(CONFIG_PATH)) {
111112
const data = readFileSync(CONFIG_PATH, 'utf8');
112-
const saved = JSON.parse(data);
113-
// Merge with defaults, then apply env vars
114-
const config = { ...DEFAULT_CONFIG, ...saved };
115-
applyEnvVars(config);
116-
return config;
113+
const parsed = JSON.parse(data);
114+
// Validate with zod schema, fall back to defaults on invalid config
115+
const validated = parseConfigSafe(
116+
SMSConfigSchema,
117+
{ ...DEFAULT_CONFIG, ...parsed },
118+
DEFAULT_CONFIG,
119+
'sms-notify'
120+
);
121+
applyEnvVars(validated);
122+
return validated;
117123
}
118124
} catch {
119125
// Use defaults

src/hooks/sms-webhook.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,21 @@ import { join } from 'path';
1717
import { homedir } from 'os';
1818
import { createHmac } from 'crypto';
1919
import { execFileSync } from 'child_process';
20-
import { processIncomingResponse, loadSMSConfig } from './sms-notify.js';
21-
import { queueAction, executeActionSafe } from './sms-action-runner.js';
20+
import {
21+
processIncomingResponse,
22+
loadSMSConfig,
23+
cleanupExpiredPrompts,
24+
} from './sms-notify.js';
25+
import {
26+
queueAction,
27+
executeActionSafe,
28+
cleanupOldActions,
29+
} from './sms-action-runner.js';
2230
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
2331

32+
// Cleanup interval (5 minutes)
33+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
34+
2435
// Security constants
2536
const MAX_BODY_SIZE = 50 * 1024; // 50KB max body
2637
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
@@ -408,6 +419,24 @@ export function startWebhookServer(port: number = 3456): void {
408419
`[sms-webhook] Status callback: http://localhost:${port}/sms/status`
409420
);
410421
console.log(`[sms-webhook] Configure these URLs in Twilio console`);
422+
423+
// Start timed cleanup of expired prompts and old actions
424+
setInterval(() => {
425+
try {
426+
const expiredPrompts = cleanupExpiredPrompts();
427+
const oldActions = cleanupOldActions();
428+
if (expiredPrompts > 0 || oldActions > 0) {
429+
console.log(
430+
`[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions`
431+
);
432+
}
433+
} catch {
434+
// Ignore cleanup errors
435+
}
436+
}, CLEANUP_INTERVAL_MS);
437+
console.log(
438+
`[sms-webhook] Cleanup interval: every ${CLEANUP_INTERVAL_MS / 1000}s`
439+
);
411440
});
412441
}
413442

0 commit comments

Comments
 (0)