diff --git a/skills/dispatch-webhook/handler.ts b/skills/dispatch-webhook/handler.ts index b2367c3..2712ea9 100644 --- a/skills/dispatch-webhook/handler.ts +++ b/skills/dispatch-webhook/handler.ts @@ -12,6 +12,56 @@ interface DispatchInput { retries?: number; } +const ALLOWED_TARGETS = new Set(['vps-a', 'vps-b']); + +function clampInt(value: unknown, min: number, max: number, fallback: number): number { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); +} + +function sanitizeTaskId(taskId: unknown): string { + if (taskId == null) return ''; + return String(taskId).replace(/[\r\n]/g, '').slice(0, 128); +} + +function validateInput(raw: any): DispatchInput { + const input = raw as Partial; + + if (!input || typeof input !== 'object') { + throw new Error('dispatch-webhook 輸入格式錯誤:必須提供 input 物件'); + } + + if (!input.target || !ALLOWED_TARGETS.has(input.target as DispatchInput['target'])) { + throw new Error('dispatch-webhook 參數錯誤:target 必須是 vps-a 或 vps-b'); + } + + if (!input.webhookUrl || typeof input.webhookUrl !== 'string') { + throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(input.webhookUrl); + } catch { + throw new Error('Webhook URL 格式錯誤,請提供有效的 http/https URL'); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error('Webhook URL 協定不支援,僅允許 http 或 https'); + } + + if (!input.webhookToken || typeof input.webhookToken !== 'string') { + throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`); + } + + if (!input.payload || typeof input.payload !== 'object' || Array.isArray(input.payload)) { + throw new Error('dispatch-webhook 參數錯誤:payload 必須是 JSON 物件'); + } + + return input as DispatchInput; +} + async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -23,17 +73,11 @@ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: nu } export async function handler(ctx: any) { - const input: DispatchInput = ctx.input || ctx.params; + const input = validateInput(ctx.input || ctx.params); - if (!input.webhookUrl) { - throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`); - } - if (!input.webhookToken) { - throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`); - } - - const timeoutMs = input.timeoutMs ?? 30000; - const maxRetries = input.retries ?? 3; + const timeoutMs = clampInt(input.timeoutMs, 1000, 120000, 30000); + const maxRetries = clampInt(input.retries, 1, 5, 3); + const taskIdHeader = sanitizeTaskId(input.payload.task_id); let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { @@ -46,7 +90,7 @@ export async function handler(ctx: any) { 'Content-Type': 'application/json', 'Authorization': `Bearer ${input.webhookToken}`, 'X-OpenClaw-Version': '1.0', - 'X-OpenClaw-Task-Id': String(input.payload.task_id || ''), + 'X-OpenClaw-Task-Id': taskIdHeader, }, body: JSON.stringify(input.payload), }, @@ -72,8 +116,8 @@ export async function handler(ctx: any) { }; } catch (err: any) { - lastError = err; - if (err.message?.includes('401') || err.message?.includes('Token')) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (lastError.message.includes('401') || lastError.message.includes('Token')) { break; // 認證錯誤不重試 } if (attempt < maxRetries) {