Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43b3fd0
fix(qqbot): validate gateway URL protocol to prevent SSRF
Eric-GoodBoy-Tech Jul 2, 2026
9c248ba
fix(qqbot): atomic state persistence and error log sanitization
Eric-GoodBoy-Tech Jul 2, 2026
8774eb9
fix(qqbot): add replyMsgId validation in restoreQQState()
Eric-GoodBoy-Tech Jul 2, 2026
9786cd9
fix(qqbot): add test coverage for restoreQQState filters, atomic writ…
Eric-GoodBoy-Tech Jul 2, 2026
46910de
docs(qqbot): update restoreQQState doc and add disposed guard comment
Eric-GoodBoy-Tech Jul 3, 2026
3ec3877
docs(qqbot): update validateGatewayUrl docstring to reflect TLS enfor…
Eric-GoodBoy-Tech Jul 3, 2026
231f7c7
fix(qqbot): address wenshao review — hostname rejection, error saniti…
Eric-GoodBoy-Tech Jul 3, 2026
ebd6f30
fix(qqbot): address PR review — drain body, narrow gateway hostname
Eric-GoodBoy-Tech Jul 3, 2026
2ccbfe6
test(qqbot): tighten token-error assertion to exact match
Eric-GoodBoy-Tech Jul 3, 2026
14ea726
test(qqbot): add connect retry sanitization + msgSeqMap edge-case tests
Eric-GoodBoy-Tech Jul 3, 2026
a1367e0
fix(qqbot): address wenshao review round 3 — error preservation, vali…
Eric-GoodBoy-Tech Jul 3, 2026
3e8f713
test(qqbot): fix connect retry sanitization assertion — sanitizeLogTe…
Eric-GoodBoy-Tech Jul 3, 2026
707b752
fix(qqbot): address wenshao review — error preservation, validation l…
Eric-GoodBoy-Tech Jul 3, 2026
483305e
fix(qqbot): add Array.isArray guards and replyMsgId drop logging in r…
Eric-GoodBoy-Tech Jul 3, 2026
3da730f
fix(qqbot): drain response body in token error path, sanitize URL Typ…
Eric-GoodBoy-Tech Jul 3, 2026
eff0084
fix(qqbot): test Infinity rejection via 1e999 raw JSON, not JSON.stri…
Eric-GoodBoy-Tech Jul 3, 2026
9eff75f
fix(qqbot): address PR #6200 review — beforeExit hook, key validation…
Eric-GoodBoy-Tech Jul 3, 2026
2acf178
fix(qqbot): address PR #6200 review round — non-object JSON restore, …
Eric-GoodBoy-Tech Jul 3, 2026
a9bd70c
fix(qqbot): hoist tmpPath declaration before try block in saveQQState
Eric-GoodBoy-Tech Jul 3, 2026
548512a
fix(qqbot): update beforeExit JSDoc to accurately describe behavior
Eric-GoodBoy-Tech Jul 3, 2026
86615ea
fix(qqbot): drain response body in fetchGatewayUrl error path
Eric-GoodBoy-Tech Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 142 additions & 26 deletions packages/channels/qqbot/src/QQChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ import type {
ChannelAgentBridge,
} from '@qwen-code/channel-base';
import WebSocket from 'ws';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
renameSync,
unlinkSync,
} from 'node:fs';
import { join } from 'node:path';
import { OpCode, Intent } from './types.js';
import type {
Expand Down Expand Up @@ -128,6 +135,8 @@ export class QQChannel extends ChannelBase {
private lastHeartbeatAck: number = 0;
/** Debounce timer for saveQQState to avoid blocking event loop. */
private saveTimer: ReturnType<typeof setTimeout> | null = null;
/** beforeExit hook to flush state when the event loop drains naturally. Does NOT fire for SIGKILL, OOM kills, or uncaughtException. */
private beforeExitHook: (() => void) | null = null;
/** Timer for reconnectWithRetry fallback (unref'd so it doesn't block exit). */
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Guard against parallel reconnectWithRetry chains from stale close events. */
Expand Down Expand Up @@ -199,16 +208,30 @@ export class QQChannel extends ChannelBase {
try {
await this.fetchToken();
await this.connectGateway();
// Register beforeExit hook so the unref'd debounce timer's unflushed
// state is persisted when the event loop drains naturally. Does NOT
// fire for SIGKILL, OOM kills, or uncaughtException.
if (this.beforeExitHook) {
process.off('beforeExit', this.beforeExitHook);
}
this.beforeExitHook = () => this.flushQQState();
process.on('beforeExit', this.beforeExitHook);
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
return;
} catch (e: unknown) {
if (attempt < 2) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${msg}, retrying...\n`,
`[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${sanitizeLogText(msg, 200)}, retrying...\n`,
);
await this.sleep(2000);
} else {
throw e;
// Final attempt: wrap the connection error with sanitized text.
// The sanitizeLogText path is exercised by the existing connect gateway
// retry tests in send.test.ts (gateway reconnect timer block).
throw new Error(
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
sanitizeLogText(e instanceof Error ? e.message : String(e), 200),
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
{ cause: e },
);
}
}
}
Expand Down Expand Up @@ -253,7 +276,7 @@ export class QQChannel extends ChannelBase {
if (!resp.ok && useMarkdown) {
const errBody = await resp.text().catch(() => '');
process.stderr.write(
`[QQ:${this.name}] Markdown rejected (HTTP ${resp.status}: ${errBody.slice(0, 100)}), retrying as plain text\n`,
`[QQ:${this.name}] Markdown rejected (HTTP ${resp.status}: ${sanitizeLogText(errBody, 200)}), retrying as plain text\n`,
);
const plainBody: Record<string, unknown> = {
content: chunk,
Expand All @@ -275,14 +298,16 @@ export class QQChannel extends ChannelBase {
// Drain response body to avoid socket leak
const errBody = await resp.text().catch(() => '');
process.stderr.write(
`[QQ:${this.name}] Send HTTP ${resp.status} (msg_seq=${body['msg_seq'] ?? '-'}): ${errBody.slice(0, 200)}\n`,
`[QQ:${this.name}] Send HTTP ${resp.status} (msg_seq=${body['msg_seq'] ?? '-'}): ${sanitizeLogText(errBody, 200)}\n`,
);
break; // stop sending on failure to avoid msg_seq gaps
}
// Only persist seq on success
if (msgId) this.msgSeqMap.set(msgId, nextSeq);
} catch (e) {
process.stderr.write(`[QQ:${this.name}] Send error: ${e}\n`);
process.stderr.write(
`[QQ:${this.name}] Send error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
);
break;
}
}
Expand Down Expand Up @@ -326,6 +351,10 @@ export class QQChannel extends ChannelBase {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.beforeExitHook) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: disconnect() has no idempotency guard. If called twice (e.g. from both the error handler and the shutdown path), flushQQState() runs twice, maps get cleared twice, and this.ws.close(1000) is called on an already-nulled socket. The new beforeExitHook cleanup added here is safe (guarded by the null check), but the method as a whole would benefit from an early return:

disconnect(): void {
  if (this.disposed) return;
  this.disposed = true;
  // ... rest
}

saveQQState already has a disposed guard and flushQQState is intentionally unguarded (documented in the comment below) — adding the guard at the top of disconnect() would be consistent with the saveQQState pattern.

process.off('beforeExit', this.beforeExitHook);
this.beforeExitHook = null;
}
this.flushQQState();
this.backupGlobalSessions();
if (this.ws) {
Expand Down Expand Up @@ -362,22 +391,36 @@ export class QQChannel extends ChannelBase {

/** Debounced state persistence to avoid blocking event loop. */
private saveQQState(): void {
// NOTE: guarded here; flushQQState() is intentionally NOT — disconnect()
// sets disposed=true *before* calling it, so it must still write final state.
if (this.disposed) return;
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
if (this.saveTimer) clearTimeout(this.saveTimer);
const tmpPath = this.qqStatePath + '.tmp';
this.saveTimer = setTimeout(() => {
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
if (this.disposed) return;
try {
writeFileSync(
this.qqStatePath,
tmpPath,
JSON.stringify({
chatTypeMap: Array.from(this.chatTypeMap.entries()),
replyMsgId: Array.from(this.replyMsgId.entries()),
msgSeqMap: Array.from(this.msgSeqMap.entries()),
}),
{ mode: 0o600 },
);
} catch {
/* best-effort */
renameSync(tmpPath, this.qqStatePath);
} catch (e) {
try {
unlinkSync(tmpPath);
} catch {
/* best-effort */
}
process.stderr.write(
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
`[QQ:${this.name}] saveQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
);
}
}, 500);
this.saveTimer.unref();
}
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.

/** Flush pending state writes immediately (called on disconnect). */
Expand All @@ -386,37 +429,104 @@ export class QQChannel extends ChannelBase {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
const tmpPath = this.qqStatePath + '.tmp';
try {
writeFileSync(
this.qqStatePath,
tmpPath,
JSON.stringify({
chatTypeMap: Array.from(this.chatTypeMap.entries()),
replyMsgId: Array.from(this.replyMsgId.entries()),
msgSeqMap: Array.from(this.msgSeqMap.entries()),
}),
{ mode: 0o600 },
);
renameSync(tmpPath, this.qqStatePath);
} catch (e) {
try {
unlinkSync(tmpPath);
} catch {
/* best-effort */
}
process.stderr.write(
`[QQ:${this.name}] flushQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
);
} catch {
/* best-effort */
}
}

/**
* Restore QQ routing state from disk.
* Trusts persisted JSON — if the file is corrupt, new Map() may create
* entries with undefined values, causing get()===undefined to fall through
* to default routing (C2C). This is acceptable for a rare edge case.
* Validates and filters every entry on restore — corrupt or unexpected
* entries (e.g. unknown chat types, oversized replyMsgIds, negative seqs)
* are silently dropped so they don't propagate into runtime routing.
*/
private restoreQQState(): boolean {
try {
if (!existsSync(this.qqStatePath)) return false;
const raw = JSON.parse(readFileSync(this.qqStatePath, 'utf-8'));
if (raw.chatTypeMap) this.chatTypeMap = new Map(raw.chatTypeMap);
if (raw.replyMsgId) this.replyMsgId = new Map(raw.replyMsgId);
if (raw.msgSeqMap) this.msgSeqMap = new Map(raw.msgSeqMap);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
process.stderr.write(
`[QQ:${this.name}] Invalid QQ state file (not an object), ignoring\n`,
);
return false;
}
if (raw.chatTypeMap && Array.isArray(raw.chatTypeMap)) {
const rawCT = raw.chatTypeMap as Array<[string, unknown]>;
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
// Validate: only accept 'c2c' | 'group' values
this.chatTypeMap = new Map(
rawCT.filter(
([k, v]) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The filter callback uses destructuring ([k, v]) => which throws a TypeError when the array contains a non-array entry (e.g. null, 42, true). Although Array.isArray(raw.chatTypeMap) confirms the container is an array, JSON.parse happily produces arrays with non-tuple entries — [null, 42, ["a","c2c"]] is valid JSON.

The same pattern repeats for replyMsgId (line ~497) and msgSeqMap (line ~515).

Suggested fix — add an Array.isArray(e) guard before destructuring:

rawCT.filter(
  (e) => {
    if (!Array.isArray(e) || e.length !== 2) return false;
    const [k, v] = e;
    return typeof k === 'string' && k.length <= 256 && (v === 'c2c' || v === 'group');
  },
)

This also affects the existing tests — the 'filters chatTypeMap' test only uses well-formed tuples, so this crash path isn't exercised.

typeof k === 'string' &&
k.length <= 256 &&
(v === 'c2c' || v === 'group'),
),
) as Map<string, 'c2c' | 'group'>;
const dropped = rawCT.length - this.chatTypeMap.size;
if (dropped > 0)
process.stderr.write(
Comment thread
Eric-GoodBoy-Tech marked this conversation as resolved.
`[QQ:${this.name}] Dropped ${dropped} invalid chatTypeMap entries during restore\n`,
);
}
if (raw.replyMsgId && Array.isArray(raw.replyMsgId)) {
const rawRM = raw.replyMsgId as Array<[string, unknown]>;
// Validate: entries must be strings ≤ 128 chars
this.replyMsgId = new Map(
rawRM.filter(
([k, v]) =>
typeof k === 'string' &&
k.length <= 256 &&
typeof v === 'string' &&
v.length <= 128,
),
) as Map<string, string>;
const dropped = rawRM.length - this.replyMsgId.size;
if (dropped > 0)
process.stderr.write(
`[QQ:${this.name}] Dropped ${dropped} invalid replyMsgId entries during restore\n`,
);
}
if (raw.msgSeqMap && Array.isArray(raw.msgSeqMap)) {
const rawMS = raw.msgSeqMap as Array<[string, unknown]>;
// Validate: entries must be non-negative safe integers
this.msgSeqMap = new Map(
rawMS.filter(
([k, v]) =>
typeof k === 'string' &&
k.length <= 256 &&
typeof v === 'number' &&
Number.isSafeInteger(v) &&
v >= 0,
),
) as Map<string, number>;
const dropped = rawMS.length - this.msgSeqMap.size;
if (dropped > 0)
process.stderr.write(
`[QQ:${this.name}] Dropped ${dropped} invalid msgSeqMap entries during restore\n`,
);
}
return true;
} catch (e) {
process.stderr.write(
`[QQ:${this.name}] Failed to restore QQ state: ${e instanceof Error ? e.message : String(e)}\n`,
`[QQ:${this.name}] Failed to restore QQ state: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
);
return false;
}
Expand Down Expand Up @@ -549,7 +659,7 @@ export class QQChannel extends ChannelBase {
this.fetchToken().catch((e) => {
if (this.disposed) return;
process.stderr.write(
`[QQ:${this.name}] Token refresh failed: ${e}, retrying in 60s\n`,
`[QQ:${this.name}] Token refresh failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retrying in 60s\n`,
);
this.scheduleTokenRefreshRetry();
});
Expand All @@ -564,7 +674,7 @@ export class QQChannel extends ChannelBase {
this.fetchToken().catch((e) => {
if (this.disposed) return;
process.stderr.write(
`[QQ:${this.name}] Token refresh failed: ${e}, retrying in 60s\n`,
`[QQ:${this.name}] Token refresh failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retrying in 60s\n`,
);
this.scheduleTokenRefreshRetry();
});
Expand Down Expand Up @@ -611,7 +721,7 @@ export class QQChannel extends ChannelBase {
this.handleGatewayMessage(msg, resolve);
} catch (e) {
process.stderr.write(
`[QQ:${this.name}] Malformed gateway message: ${e instanceof Error ? e.message : String(e)}\n`,
`[QQ:${this.name}] Malformed gateway message: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
);
}
});
Expand Down Expand Up @@ -680,7 +790,9 @@ export class QQChannel extends ChannelBase {
});

this.ws.on('error', (e: Error) => {
process.stderr.write(`[QQ:${this.name}] WebSocket error: ${e.message}\n`);
process.stderr.write(
`[QQ:${this.name}] WebSocket error: ${sanitizeLogText(e.message, 200)}\n`,
);
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(e);
}
Expand Down Expand Up @@ -853,7 +965,7 @@ export class QQChannel extends ChannelBase {
const msg = e instanceof Error ? e.message : String(e);
const backoff = Math.min(1000 * 2 ** (attempt + 1), 30000);
process.stderr.write(
`[QQ:${this.name}] RC: ${msg} (retry in ${backoff}ms, attempt ${attempt + 1}/${maxGwRetries})\n`,
`[QQ:${this.name}] RC: ${sanitizeLogText(msg, 200)} (retry in ${backoff}ms, attempt ${attempt + 1}/${maxGwRetries})\n`,
);
if (attempt < maxGwRetries - 1) await this.sleep(backoff);
}
Expand Down Expand Up @@ -944,7 +1056,9 @@ export class QQChannel extends ChannelBase {
isMentioned: true,
isReplyToBot: false,
}).catch((e) =>
process.stderr.write(`[QQ:${this.name}] C2C handler error: ${e}\n`),
process.stderr.write(
`[QQ:${this.name}] C2C handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
),
);
}

Expand Down Expand Up @@ -1013,7 +1127,9 @@ export class QQChannel extends ChannelBase {
isReplyToBot: true,
...(isSlash ? {} : { alreadyPrefixed: true as const }),
}).catch((e) =>
process.stderr.write(`[QQ:${this.name}] Group handler error: ${e}\n`),
process.stderr.write(
`[QQ:${this.name}] Group handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`,
),
);
}
}
Loading
Loading