From 5598b3661c79a04461a735fb52acf4e3fd79c1a8 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 6 Sep 2026 14:45:35 +0800 Subject: [PATCH] Harden alarm push auth and session export ZIP paths. Ignore hub alarms before auth-ok, stop reconnecting after auth-fail, serialize sticky delivery with dispose on reset, and sanitize ZIP entry names against path traversal. Co-authored-by: Cursor --- lib/index.js | 89 ++++++++++++++++++++++++++---------- src/netx/alarm-push.ts | 28 ++++++++++-- src/netx/alarm-session.ts | 68 ++++++++++++++++++--------- src/netx/session-export.ts | 13 ++++-- test/session-export.test.mjs | 6 ++- 5 files changed, 148 insertions(+), 56 deletions(-) diff --git a/lib/index.js b/lib/index.js index 6a13d46..2fb34f3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -148,6 +148,8 @@ function startAlarmPushClient(options) { return () => {}; } let closed = false; + let haltReconnect = false; + let subscribed = false; let socket = null; let reconnectTimer; let attempt = 0; @@ -159,7 +161,7 @@ function startAlarmPushClient(options) { } }; const scheduleReconnect = (reason) => { - if (closed) + if (closed || haltReconnect) return; clearReconnect(); const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5)); @@ -173,9 +175,10 @@ function startAlarmPushClient(options) { }, delay); }; const connect = () => { - if (closed) + if (closed || haltReconnect) return; clearReconnect(); + subscribed = false; setPhase(attempt > 0 ? "reconnecting" : "connecting", wsUrl, { detail: "dialing" }); try { socket = new WS(wsUrl); @@ -199,6 +202,7 @@ function startAlarmPushClient(options) { const type = String(msg.type ?? "").toLowerCase(); if (type === "auth-ok") { attempt = 0; + subscribed = true; const now = Date.now(); setPhase("connected", wsUrl, { detail: String(msg.user ?? "ok"), @@ -210,14 +214,23 @@ function startAlarmPushClient(options) { } if (type === "auth-fail") { const err = String(msg.error ?? "auth_failed"); - log.error?.("netxops alarm-push: auth failed (%s)", err); + log.error?.("netxops alarm-push: auth failed (%s) — not reconnecting until settings/token change", err); + subscribed = false; + haltReconnect = true; + clearReconnect(); setPhase("auth_failed", wsUrl, { detail: err, lastError: err }); - socket?.close(); + try { + socket?.close(); + } catch {} return; } if (type === "pong") return; if (type === "event" && String(msg.event ?? "") === "netx.alarm") { + if (!subscribed) { + log.warn?.("netxops alarm-push: ignoring alarm before auth-ok"); + return; + } const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {}; Promise.resolve(options.onAlarm(payload)).catch((error) => { log.warn?.("netxops alarm-push: handler failed:", error); @@ -226,7 +239,8 @@ function startAlarmPushClient(options) { }); socket.addEventListener("close", () => { socket = null; - if (!closed) + subscribed = false; + if (!closed && !haltReconnect) scheduleReconnect("socket_closed"); }); socket.addEventListener("error", () => {}); @@ -356,28 +370,47 @@ var PRESET_ID = "netxops"; var PERMISSION_PRESET = "default"; var TITLE = "Netx 关键告警"; var sticky = null; +var deliveryChain = Promise.resolve(); function resolveWorkspacePath() { const fromEnv = process.env.DSH_HOME?.trim(); const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh"); return join(home, "workspaces", "netxops-alarms"); } -async function deliverAlarmToSession(ctx, payload, lang = "zh") { - const prompt = formatAlarmPrompt(payload, lang); - const agents = ctx.agents; - if (!agents || typeof agents.create !== "function") { - ctx.logger.warn("netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session"); +async function disposeSticky(handle) { + if (!handle) return; - } - if (sticky?.agent && typeof sticky.agent.followup === "function") { - try { - await followup(ctx, sticky.agent, prompt); + try { + await handle.dispose?.(); + } catch {} +} +async function deliverAlarmToSession(ctx, payload, lang = "zh") { + const run = async () => { + const prompt = formatAlarmPrompt(payload, lang); + const agents = ctx.agents; + if (!agents || typeof agents.create !== "function") { + ctx.logger.warn("netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session"); return; - } catch (error) { - ctx.logger.warn("netxops alarm-push: sticky followup failed, recreating session: %s", error); - sticky = null; } - } - await createStickySession(ctx, prompt); + if (sticky?.agent && typeof sticky.agent.followup === "function") { + try { + await followup(ctx, sticky.agent, prompt); + return; + } catch (error) { + ctx.logger.warn("netxops alarm-push: sticky followup failed, recreating session: %s", error); + const previous = sticky; + sticky = null; + await disposeSticky(previous); + } + } + await createStickySession(ctx, prompt); + }; + const next = deliveryChain.then(run, run); + deliveryChain = next.then(() => { + return; + }, () => { + return; + }); + await next; } async function followup(ctx, agent, prompt) { let createUserMessage; @@ -387,7 +420,7 @@ async function followup(ctx, agent, prompt) { if (typeof createUserMessage !== "function") throw new Error("createUserMessage missing"); const summary = typeof mod.boundContextSummary === "function" ? mod.boundContextSummary("netx key alarm") : "netx key alarm"; - agent.followup(createUserMessage({ + await Promise.resolve(agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "webhook", @@ -396,7 +429,7 @@ async function followup(ctx, agent, prompt) { form: "notice", summary } - })); + }))); } catch (error) { const anyAgent = agent; if (typeof anyAgent.prompt === "function") { @@ -455,7 +488,11 @@ async function createStickySession(ctx, prompt) { sessionTitle.rename(handle.agent.session, TITLE); } await followup(ctx, handle.agent, prompt); - sticky = { sessionId, agent: handle.agent }; + sticky = { + sessionId, + agent: handle.agent, + dispose: typeof handle.dispose === "function" ? () => handle.dispose() : undefined + }; ctx.logger.info("netxops alarm-push: opened sticky session %s", sessionId); } catch (error) { try { @@ -465,7 +502,9 @@ async function createStickySession(ctx, prompt) { } } function resetAlarmSession() { + const previous = sticky; sticky = null; + disposeSticky(previous); } // src/netx/capability-groups.ts @@ -1619,7 +1658,9 @@ async function getSessionsExportStatus(ctx, signal) { } } function safePathSegment(id) { - return id.replace(/[^A-Za-z0-9_-]/g, "_"); + const base = String(id || "").replace(/\\/g, "/").split("/").pop() ?? ""; + const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, ""); + return cleaned || "unnamed"; } function sessionsExportZipFilename(exportedAt = new Date, host = osHostname()) { const stamp = [ @@ -1670,7 +1711,7 @@ async function* sessionsExportEntries(ctx, signal) { skipped.push({ id, reason: "no stored artifact" }); continue; } - const filename = raw.filename && raw.filename.length > 0 ? raw.filename : "session.jsonl"; + const filename = safePathSegment(raw.filename && raw.filename.length > 0 ? raw.filename : "session.jsonl") || "session.jsonl"; const path = `sessions/${safePathSegment(id)}/${filename}`; artifactEntries.push({ path, content: raw.content }); included.push({ diff --git a/src/netx/alarm-push.ts b/src/netx/alarm-push.ts index 9c7b793..8fe6e4b 100644 --- a/src/netx/alarm-push.ts +++ b/src/netx/alarm-push.ts @@ -148,6 +148,10 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } let closed = false + /** After auth-fail, do not reconnect until the client is disposed/restarted. */ + let haltReconnect = false + /** Only forward alarms after a successful auth-ok on the current socket. */ + let subscribed = false let socket: WebSocket | null = null let reconnectTimer: ReturnType | undefined let attempt = 0 @@ -161,7 +165,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } const scheduleReconnect = (reason: string): void => { - if (closed) return + if (closed || haltReconnect) return clearReconnect() const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5))) attempt += 1 @@ -173,8 +177,9 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } const connect = (): void => { - if (closed) return + if (closed || haltReconnect) return clearReconnect() + subscribed = false setPhase(attempt > 0 ? 'reconnecting' : 'connecting', wsUrl, { detail: 'dialing' }) try { socket = new WS(wsUrl) @@ -200,6 +205,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi const type = String(msg.type ?? '').toLowerCase() if (type === 'auth-ok') { attempt = 0 + subscribed = true const now = Date.now() setPhase('connected', wsUrl, { detail: String(msg.user ?? 'ok'), @@ -211,13 +217,24 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } if (type === 'auth-fail') { const err = String(msg.error ?? 'auth_failed') - log.error?.('netxops alarm-push: auth failed (%s)', err) + log.error?.('netxops alarm-push: auth failed (%s) — not reconnecting until settings/token change', err) + subscribed = false + haltReconnect = true + clearReconnect() setPhase('auth_failed', wsUrl, { detail: err, lastError: err }) - socket?.close() + try { + socket?.close() + } catch { + // ignore + } return } if (type === 'pong') return if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') { + if (!subscribed) { + log.warn?.('netxops alarm-push: ignoring alarm before auth-ok') + return + } const payload = msg.payload && typeof msg.payload === 'object' ? msg.payload as KeyAlarmPayload : {} @@ -229,7 +246,8 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi socket.addEventListener('close', () => { socket = null - if (!closed) scheduleReconnect('socket_closed') + subscribed = false + if (!closed && !haltReconnect) scheduleReconnect('socket_closed') }) socket.addEventListener('error', () => { diff --git a/src/netx/alarm-session.ts b/src/netx/alarm-session.ts index 8b2d271..e0aa06f 100644 --- a/src/netx/alarm-session.ts +++ b/src/netx/alarm-session.ts @@ -19,9 +19,12 @@ interface StickyHandle { sessionId: string // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent type varies by DSH version agent: any + dispose?: () => Promise | void } let sticky: StickyHandle | null = null +/** Serialize create/followup so concurrent alarms cannot open multiple sticky sessions. */ +let deliveryChain: Promise = Promise.resolve() function resolveWorkspacePath(): string { const fromEnv = process.env.DSH_HOME?.trim() @@ -29,6 +32,15 @@ function resolveWorkspacePath(): string { return join(home, 'workspaces', 'netxops-alarms') } +async function disposeSticky(handle: StickyHandle | null): Promise { + if (!handle) return + try { + await handle.dispose?.() + } catch { + // Best-effort teardown; the process may already have dropped the agent. + } +} + /** * Open or reuse a Netx Ops session and append the alarm as a user followup. * @param ctx - host cordis context (may lack session services on minimal profiles). @@ -40,27 +52,35 @@ export async function deliverAlarmToSession( payload: KeyAlarmPayload, lang = 'zh', ): Promise { - const prompt = formatAlarmPrompt(payload, lang) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const agents = (ctx as any).agents - if (!agents || typeof agents.create !== 'function') { - ctx.logger.warn( - 'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session', - ) - return - } - - if (sticky?.agent && typeof sticky.agent.followup === 'function') { - try { - await followup(ctx, sticky.agent, prompt) + const run = async (): Promise => { + const prompt = formatAlarmPrompt(payload, lang) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agents = (ctx as any).agents + if (!agents || typeof agents.create !== 'function') { + ctx.logger.warn( + 'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session', + ) return - } catch (error) { - ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error) - sticky = null } + + if (sticky?.agent && typeof sticky.agent.followup === 'function') { + try { + await followup(ctx, sticky.agent, prompt) + return + } catch (error) { + ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error) + const previous = sticky + sticky = null + await disposeSticky(previous) + } + } + + await createStickySession(ctx, prompt) } - await createStickySession(ctx, prompt) + const next = deliveryChain.then(run, run) + deliveryChain = next.then(() => undefined, () => undefined) + await next } async function followup(ctx: Context, agent: { followup: (msg: unknown) => unknown }, prompt: string): Promise { @@ -78,7 +98,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno const summary = typeof mod.boundContextSummary === 'function' ? mod.boundContextSummary('netx key alarm') : 'netx key alarm' - agent.followup(createUserMessage({ + await Promise.resolve(agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'webhook', @@ -87,7 +107,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno form: 'notice', summary, }, - })) + }))) } catch (error) { // Fallback: some hosts accept a plain text followup helper on agents. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -162,7 +182,11 @@ async function createStickySession(ctx: Context, prompt: string): Promise sessionTitle.rename(handle.agent.session, TITLE) } await followup(ctx, handle.agent, prompt) - sticky = { sessionId, agent: handle.agent } + sticky = { + sessionId, + agent: handle.agent, + dispose: typeof handle.dispose === 'function' ? () => handle.dispose() : undefined, + } ctx.logger.info('netxops alarm-push: opened sticky session %s', sessionId) } catch (error) { try { @@ -174,7 +198,9 @@ async function createStickySession(ctx: Context, prompt: string): Promise } } -/** Drop the sticky handle (tests / dispose). */ +/** Drop and dispose the sticky handle (config restart / plugin dispose / tests). */ export function resetAlarmSession(): void { + const previous = sticky sticky = null + void disposeSticky(previous) } diff --git a/src/netx/session-export.ts b/src/netx/session-export.ts index 71e94db..25b2e73 100644 --- a/src/netx/session-export.ts +++ b/src/netx/session-export.ts @@ -162,11 +162,14 @@ export async function getSessionsExportStatus( } /** - * Sanitize one path segment for ZIP entry names. - * @param id - raw session id or hostname fragment. + * Sanitize one path segment for ZIP entry names (no separators / traversal). + * Keeps a single basename; dots in extensions like `.jsonl` are allowed. + * @param id - raw session id, hostname fragment, or artifact filename. */ export function safePathSegment(id: string): string { - return id.replace(/[^A-Za-z0-9_-]/g, '_') + const base = String(id || '').replace(/\\/g, '/').split('/').pop() ?? '' + const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '') + return cleaned || 'unnamed' } /** @@ -246,7 +249,9 @@ export async function* sessionsExportEntries( skipped.push({ id, reason: 'no stored artifact' }) continue } - const filename = raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl' + const filename = safePathSegment( + raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl', + ) || 'session.jsonl' const path = `sessions/${safePathSegment(id)}/${filename}` artifactEntries.push({ path, content: raw.content }) included.push({ diff --git a/test/session-export.test.mjs b/test/session-export.test.mjs index c113a90..ddcea1c 100644 --- a/test/session-export.test.mjs +++ b/test/session-export.test.mjs @@ -7,8 +7,10 @@ import { sessionsExportZipFilename, } from '../src/netx/session-export.ts' -test('safePathSegment strips path separators and dots', () => { - assert.equal(safePathSegment('../a/b'), '___a_b') +test('safePathSegment strips path separators and keeps filename extensions', () => { + assert.equal(safePathSegment('../a/b'), 'b') + assert.equal(safePathSegment('..\\evil.jsonl'), 'evil.jsonl') + assert.equal(safePathSegment('session.jsonl'), 'session.jsonl') assert.equal(safePathSegment('netxops-alarm-abc'), 'netxops-alarm-abc') })