improve(dispatch-webhook): 強化輸入驗證與參數邊界防護 #2
@@ -12,6 +12,56 @@ interface DispatchInput {
|
|||||||
retries?: number;
|
retries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALLOWED_TARGETS = new Set<DispatchInput['target']>(['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<DispatchInput>;
|
||||||
|
|
||||||
|
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<Response> {
|
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
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) {
|
export async function handler(ctx: any) {
|
||||||
const input: DispatchInput = ctx.input || ctx.params;
|
const input = validateInput(ctx.input || ctx.params);
|
||||||
|
|
||||||
if (!input.webhookUrl) {
|
const timeoutMs = clampInt(input.timeoutMs, 1000, 120000, 30000);
|
||||||
throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`);
|
const maxRetries = clampInt(input.retries, 1, 5, 3);
|
||||||
}
|
const taskIdHeader = sanitizeTaskId(input.payload.task_id);
|
||||||
if (!input.webhookToken) {
|
|
||||||
throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = input.timeoutMs ?? 30000;
|
|
||||||
const maxRetries = input.retries ?? 3;
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
@@ -46,7 +90,7 @@ export async function handler(ctx: any) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${input.webhookToken}`,
|
'Authorization': `Bearer ${input.webhookToken}`,
|
||||||
'X-OpenClaw-Version': '1.0',
|
'X-OpenClaw-Version': '1.0',
|
||||||
'X-OpenClaw-Task-Id': String(input.payload.task_id || ''),
|
'X-OpenClaw-Task-Id': taskIdHeader,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(input.payload),
|
body: JSON.stringify(input.payload),
|
||||||
},
|
},
|
||||||
@@ -72,8 +116,8 @@ export async function handler(ctx: any) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = err;
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
if (err.message?.includes('401') || err.message?.includes('Token')) {
|
if (lastError.message.includes('401') || lastError.message.includes('Token')) {
|
||||||
break; // 認證錯誤不重試
|
break; // 認證錯誤不重試
|
||||||
}
|
}
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
|
|||||||
Reference in New Issue
Block a user