2026-06-23 17:45:31 +08:00
|
|
|
import http from 'node:http';
|
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import { mkdir } from 'node:fs/promises';
|
|
|
|
|
|
|
|
|
|
import makeWASocket, {
|
|
|
|
|
Browsers,
|
|
|
|
|
DisconnectReason,
|
|
|
|
|
fetchLatestBaileysVersion,
|
|
|
|
|
useMultiFileAuthState,
|
|
|
|
|
} from 'baileys';
|
|
|
|
|
import QRCode from 'qrcode';
|
|
|
|
|
|
|
|
|
|
const PORT = Number.parseInt(process.env.WHATSAPP_GATEWAY_PORT || '3005', 10);
|
|
|
|
|
const HOST = process.env.WHATSAPP_GATEWAY_HOST || '127.0.0.1';
|
|
|
|
|
const AUTH_TOKEN = String(process.env.WHATSAPP_GATEWAY_TOKEN || '').trim();
|
|
|
|
|
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
|
|
|
const DATA_DIR =
|
|
|
|
|
process.env.WHATSAPP_GATEWAY_DATA_DIR ||
|
|
|
|
|
path.join(os.homedir(), '.ragflow', 'whatsapp-gateway');
|
|
|
|
|
|
|
|
|
|
function now() {
|
|
|
|
|
return Date.now() / 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeJid(chatId) {
|
|
|
|
|
const raw = String(chatId || '').trim();
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
if (raw.includes('@')) {
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
const digits = raw.replace(/\D/g, '');
|
|
|
|
|
if (!digits) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return `${digits}@s.whatsapp.net`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectChatType(jid) {
|
|
|
|
|
if (jid.endsWith('@g.us')) {
|
|
|
|
|
return 'group';
|
|
|
|
|
}
|
|
|
|
|
if (jid.endsWith('@newsletter')) {
|
|
|
|
|
return 'channel';
|
|
|
|
|
}
|
|
|
|
|
return 'dm';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractText(message) {
|
|
|
|
|
if (!message) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
message.conversation ||
|
|
|
|
|
message.extendedTextMessage?.text ||
|
|
|
|
|
message.imageMessage?.caption ||
|
|
|
|
|
message.videoMessage?.caption ||
|
|
|
|
|
message.documentMessage?.caption ||
|
|
|
|
|
message.buttonsResponseMessage?.selectedButtonId ||
|
|
|
|
|
message.listResponseMessage?.title ||
|
|
|
|
|
''
|
|
|
|
|
).trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeMessageKey(message) {
|
|
|
|
|
return {
|
|
|
|
|
remoteJid: message.key?.remoteJid || '',
|
|
|
|
|
fromMe: Boolean(message.key?.fromMe),
|
|
|
|
|
id: message.key?.id || '',
|
|
|
|
|
participant: message.key?.participant || '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildWsFrame(text) {
|
|
|
|
|
const data = Buffer.from(String(text), 'utf8');
|
|
|
|
|
let header;
|
|
|
|
|
if (data.length < 126) {
|
|
|
|
|
header = Buffer.alloc(2);
|
|
|
|
|
header[1] = data.length;
|
|
|
|
|
} else if (data.length < 65536) {
|
|
|
|
|
header = Buffer.alloc(4);
|
|
|
|
|
header[1] = 126;
|
|
|
|
|
header.writeUInt16BE(data.length, 2);
|
|
|
|
|
} else {
|
|
|
|
|
header = Buffer.alloc(10);
|
|
|
|
|
header[1] = 127;
|
|
|
|
|
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
|
|
|
}
|
|
|
|
|
header[0] = 0x81;
|
|
|
|
|
return Buffer.concat([header, data]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendWsText(socket, payload) {
|
|
|
|
|
socket.write(buildWsFrame(JSON.stringify(payload)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAuthorized(req) {
|
|
|
|
|
if (!AUTH_TOKEN) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const auth = String(req.headers.authorization || '').trim();
|
|
|
|
|
return auth === `Bearer ${AUTH_TOKEN}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class WhatsAppSession {
|
|
|
|
|
constructor(sessionKey) {
|
|
|
|
|
this.sessionKey = sessionKey;
|
|
|
|
|
this.sessionDir = path.join(DATA_DIR, sessionKey);
|
|
|
|
|
this.status = 'stopped';
|
|
|
|
|
this.lastError = '';
|
|
|
|
|
this.qrDataUrl = '';
|
|
|
|
|
this.qrUpdatedAt = 0;
|
|
|
|
|
this.connectedAt = 0;
|
|
|
|
|
this.sessionId = '';
|
|
|
|
|
this.authRegistered = false;
|
|
|
|
|
this.lastSnapshotAt = 0;
|
|
|
|
|
this.eventSeq = 0;
|
|
|
|
|
this.events = [];
|
|
|
|
|
this.messageStore = new Map();
|
|
|
|
|
this.sock = null;
|
|
|
|
|
this.saveCreds = null;
|
|
|
|
|
this.starting = null;
|
|
|
|
|
this.stopping = false;
|
|
|
|
|
this.restartTimer = null;
|
|
|
|
|
this.subscribers = new Set();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addSubscriber(socket, afterSeq) {
|
|
|
|
|
const subscriber = { socket };
|
|
|
|
|
this.subscribers.add(subscriber);
|
|
|
|
|
socket.on('close', () => {
|
|
|
|
|
this.subscribers.delete(subscriber);
|
|
|
|
|
});
|
|
|
|
|
socket.on('error', () => {
|
|
|
|
|
this.subscribers.delete(subscriber);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sendWsText(socket, { type: 'snapshot', data: this.snapshot() });
|
|
|
|
|
const backlog = this.listEvents(afterSeq);
|
|
|
|
|
for (const event of backlog.items) {
|
|
|
|
|
sendWsText(socket, { type: 'event', data: event });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
broadcast(message) {
|
|
|
|
|
for (const subscriber of this.subscribers) {
|
|
|
|
|
try {
|
|
|
|
|
sendWsText(subscriber.socket, message);
|
|
|
|
|
} catch {
|
|
|
|
|
this.subscribers.delete(subscriber);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
snapshot() {
|
|
|
|
|
return {
|
|
|
|
|
session_key: this.sessionKey,
|
|
|
|
|
status: this.status,
|
|
|
|
|
last_error: this.lastError || null,
|
|
|
|
|
qr_data_url: this.qrDataUrl || null,
|
|
|
|
|
qr_updated_at: this.qrUpdatedAt || null,
|
|
|
|
|
connected_at: this.connectedAt || null,
|
|
|
|
|
session_id: this.sessionId || null,
|
|
|
|
|
auth_registered: this.authRegistered,
|
|
|
|
|
last_snapshot_at: this.lastSnapshotAt || null,
|
|
|
|
|
event_cursor: this.eventSeq,
|
|
|
|
|
event_queue_size: this.events.length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listEvents(afterSeq) {
|
|
|
|
|
const after = Number.isFinite(afterSeq) ? afterSeq : 0;
|
|
|
|
|
return {
|
|
|
|
|
next_cursor: this.eventSeq,
|
|
|
|
|
items: this.events.filter((event) => event.seq > after),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async start() {
|
|
|
|
|
if (this.starting) {
|
|
|
|
|
return this.starting;
|
|
|
|
|
}
|
|
|
|
|
if (this.sock) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.stopping = false;
|
|
|
|
|
this.starting = this._start().finally(() => {
|
|
|
|
|
this.starting = null;
|
|
|
|
|
});
|
|
|
|
|
return this.starting;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _start() {
|
|
|
|
|
await mkdir(this.sessionDir, { recursive: true });
|
|
|
|
|
this.status = 'connecting';
|
|
|
|
|
this.lastError = '';
|
|
|
|
|
const { state, saveCreds } = await useMultiFileAuthState(this.sessionDir);
|
|
|
|
|
this.saveCreds = saveCreds;
|
|
|
|
|
this.authRegistered = Boolean(state?.creds?.registered);
|
|
|
|
|
if (!this.authRegistered) {
|
|
|
|
|
this.status = 'qr';
|
|
|
|
|
}
|
|
|
|
|
const { version } = await fetchLatestBaileysVersion();
|
|
|
|
|
const sock = makeWASocket({
|
|
|
|
|
auth: state,
|
|
|
|
|
version,
|
|
|
|
|
browser: Browsers.ubuntu('RAGFlow'),
|
|
|
|
|
printQRInTerminal: false,
|
|
|
|
|
markOnlineOnConnect: false,
|
|
|
|
|
syncFullHistory: false,
|
|
|
|
|
getMessage: async (key) => {
|
|
|
|
|
if (!key?.id) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return this.messageStore.get(key.id);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.sock = sock;
|
|
|
|
|
|
|
|
|
|
sock.ev.on('creds.update', this.saveCreds);
|
|
|
|
|
sock.ev.on('connection.update', (update) => {
|
|
|
|
|
void this._handleConnectionUpdate(update);
|
|
|
|
|
});
|
|
|
|
|
sock.ev.on('messages.upsert', (update) => {
|
|
|
|
|
void this._handleMessagesUpsert(update);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _handleConnectionUpdate(update) {
|
|
|
|
|
if (update.qr) {
|
|
|
|
|
this.status = 'qr';
|
|
|
|
|
this.lastError = '';
|
|
|
|
|
this.qrUpdatedAt = now();
|
|
|
|
|
this.qrDataUrl = await QRCode.toDataURL(update.qr, {
|
|
|
|
|
errorCorrectionLevel: 'M',
|
|
|
|
|
margin: 2,
|
|
|
|
|
scale: 8,
|
|
|
|
|
});
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
this.broadcast({ type: 'snapshot', data: this.snapshot() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (update.connection === 'open') {
|
|
|
|
|
this.status = 'connected';
|
|
|
|
|
this.lastError = '';
|
|
|
|
|
this.connectedAt = now();
|
|
|
|
|
this.qrDataUrl = '';
|
|
|
|
|
this.sessionId = this.sock?.user?.id || this.sessionId;
|
|
|
|
|
this.authRegistered = true;
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
this.broadcast({ type: 'snapshot', data: this.snapshot() });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (update.connection === 'close') {
|
|
|
|
|
const reason = update.lastDisconnect?.error?.output?.statusCode;
|
|
|
|
|
const loggedOut = reason === DisconnectReason.loggedOut;
|
|
|
|
|
this.status = loggedOut ? 'error' : 'disconnected';
|
|
|
|
|
this.lastError =
|
|
|
|
|
update.lastDisconnect?.error?.message ||
|
|
|
|
|
(loggedOut ? 'WhatsApp session logged out.' : 'WhatsApp session disconnected.');
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
this.sock = null;
|
|
|
|
|
this.saveCreds = null;
|
|
|
|
|
this.authRegistered = false;
|
|
|
|
|
this.broadcast({ type: 'snapshot', data: this.snapshot() });
|
|
|
|
|
|
|
|
|
|
if (!this.stopping && !loggedOut) {
|
|
|
|
|
clearTimeout(this.restartTimer);
|
|
|
|
|
this.restartTimer = setTimeout(() => {
|
|
|
|
|
void this.start();
|
|
|
|
|
}, 3000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _handleMessagesUpsert(update) {
|
|
|
|
|
for (const message of update.messages || []) {
|
|
|
|
|
const key = safeMessageKey(message);
|
|
|
|
|
if (key.fromMe || !key.id || !key.remoteJid) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const text = extractText(message.message);
|
|
|
|
|
if (!text) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const jid = key.remoteJid;
|
|
|
|
|
const event = {
|
|
|
|
|
seq: ++this.eventSeq,
|
|
|
|
|
kind: 'message',
|
|
|
|
|
message_id: key.id,
|
|
|
|
|
chat_id: jid,
|
|
|
|
|
chat_type: detectChatType(jid),
|
|
|
|
|
sender_id: key.participant || jid,
|
|
|
|
|
text,
|
|
|
|
|
raw: {
|
|
|
|
|
key,
|
|
|
|
|
message: message.message,
|
|
|
|
|
pushName: message.pushName || '',
|
|
|
|
|
messageTimestamp: message.messageTimestamp || 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
this.events.push(event);
|
|
|
|
|
this.messageStore.set(key.id, message);
|
|
|
|
|
this.broadcast({ type: 'event', data: event });
|
|
|
|
|
if (this.events.length > 1000) {
|
|
|
|
|
const dropped = this.events.slice(0, this.events.length - 500);
|
|
|
|
|
this.events = this.events.slice(-500);
|
|
|
|
|
for (const oldEvent of dropped) {
|
|
|
|
|
if (oldEvent.kind === 'message' && oldEvent.message_id) {
|
|
|
|
|
this.messageStore.delete(oldEvent.message_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async send(payload) {
|
|
|
|
|
if (!this.sock) {
|
|
|
|
|
throw new Error('WhatsApp session is not running.');
|
|
|
|
|
}
|
|
|
|
|
const jid = normalizeJid(payload.chat_id);
|
|
|
|
|
if (!jid) {
|
|
|
|
|
throw new Error(`Invalid chat_id: ${payload.chat_id}`);
|
|
|
|
|
}
|
|
|
|
|
const text = String(payload.text || '');
|
|
|
|
|
const options = {};
|
|
|
|
|
if (payload.reply_to_message_id) {
|
|
|
|
|
const quoted = this.messageStore.get(String(payload.reply_to_message_id));
|
|
|
|
|
if (quoted) {
|
|
|
|
|
options.quoted = quoted;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await this.sock.sendMessage(jid, { text }, options);
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async stop() {
|
|
|
|
|
this.stopping = true;
|
|
|
|
|
clearTimeout(this.restartTimer);
|
|
|
|
|
this.restartTimer = null;
|
|
|
|
|
const sock = this.sock;
|
|
|
|
|
this.sock = null;
|
|
|
|
|
this.saveCreds = null;
|
|
|
|
|
if (sock) {
|
|
|
|
|
try {
|
|
|
|
|
sock.end?.(undefined);
|
|
|
|
|
} catch {
|
|
|
|
|
try {
|
|
|
|
|
sock.ws?.close?.();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.status = 'stopped';
|
|
|
|
|
this.lastError = '';
|
|
|
|
|
this.qrDataUrl = '';
|
|
|
|
|
this.qrUpdatedAt = 0;
|
|
|
|
|
this.connectedAt = 0;
|
|
|
|
|
this.sessionId = '';
|
|
|
|
|
this.lastSnapshotAt = now();
|
|
|
|
|
this.subscribers.clear();
|
|
|
|
|
this.events = [];
|
|
|
|
|
this.messageStore.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sessions = new Map();
|
|
|
|
|
|
|
|
|
|
function getSession(sessionKey) {
|
|
|
|
|
const key = String(sessionKey || '').trim() || 'default';
|
|
|
|
|
let session = sessions.get(key);
|
|
|
|
|
if (!session) {
|
|
|
|
|
session = new WhatsAppSession(key);
|
|
|
|
|
sessions.set(key, session);
|
|
|
|
|
}
|
|
|
|
|
return session;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getExistingSession(sessionKey) {
|
|
|
|
|
const key = String(sessionKey || '').trim() || 'default';
|
|
|
|
|
return sessions.get(key) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readBody(req) {
|
|
|
|
|
const chunks = [];
|
|
|
|
|
for await (const chunk of req) {
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
}
|
|
|
|
|
if (!chunks.length) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
|
|
|
if (!raw.trim()) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
return JSON.parse(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
|
|
|
const body = JSON.stringify(payload);
|
|
|
|
|
res.writeHead(statusCode, {
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
'content-length': Buffer.byteLength(body),
|
|
|
|
|
});
|
|
|
|
|
res.end(body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendError(res, statusCode, message) {
|
|
|
|
|
sendJson(res, statusCode, { code: statusCode, message, data: null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
|
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
|
|
|
|
|
|
|
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
|
|
|
return sendJson(res, 200, { ok: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parts[0] !== 'whatsapp' || parts.length < 2) {
|
|
|
|
|
return sendError(res, 404, 'not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isAuthorized(req)) {
|
|
|
|
|
return sendError(res, 401, 'unauthorized');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sessionKey = decodeURIComponent(parts[1]);
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'start') {
|
|
|
|
|
const session = getSession(sessionKey);
|
|
|
|
|
await session.start();
|
|
|
|
|
return sendJson(res, 200, { code: 0, message: '', data: session.snapshot() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'GET' && parts.length === 3 && parts[2] === 'status') {
|
|
|
|
|
const session = getExistingSession(sessionKey);
|
|
|
|
|
if (!session) {
|
|
|
|
|
return sendError(res, 404, 'session not found');
|
|
|
|
|
}
|
|
|
|
|
return sendJson(res, 200, { code: 0, message: '', data: session.snapshot() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'send') {
|
|
|
|
|
const session = getExistingSession(sessionKey);
|
|
|
|
|
if (!session) {
|
|
|
|
|
return sendError(res, 404, 'session not found');
|
|
|
|
|
}
|
|
|
|
|
const body = await readBody(req);
|
|
|
|
|
await session.send(body);
|
|
|
|
|
return sendJson(res, 200, { code: 0, message: '', data: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'stop') {
|
|
|
|
|
const session = getExistingSession(sessionKey);
|
|
|
|
|
if (!session) {
|
|
|
|
|
return sendError(res, 404, 'session not found');
|
|
|
|
|
}
|
|
|
|
|
await session.stop();
|
|
|
|
|
sessions.delete(sessionKey);
|
|
|
|
|
return sendJson(res, 200, { code: 0, message: '', data: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sendError(res, 404, 'not found');
|
|
|
|
|
} catch (error) {
|
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407)
## Summary
Resolves all 93 open alerts at
https://github.com/infiniflow/ragflow/security/code-scanning by rule:
| Rule | Count | Treatment |
|------|-------|-----------|
| py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing |
| go/path-injection | 15 | Real fix where possible, suppression with
rationale |
| go/request-forgery | 8 | Suppression with rationale
(operator-controlled URLs) |
| go/clear-text-logging | 10 | Real fix — log scrubbing |
| go/unsafe-quoting | 5 | Real fix — escape or refactor |
| go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment |
| go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 |
| go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range
check |
| go/insecure-hostkeycallback | 1 | Real fix — known_hosts file |
| go/disabled-certificate-check | 2 | Suppression with rationale |
| go/command-injection | 1 | Suppression (sanitized via shq()) |
| go/email-injection | 1 | Suppression with rationale |
| go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) |
| js/stack-trace-exposure | 1 | Real fix — generic client message |
| js/prototype-pollution-utility | 1 | Real fix — reject
__proto__/constructor/prototype |
| py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 |
| py/incomplete-url-substring-sanitization | 3 | Real fix —
urlparse(hostname) |
| py/paramiko-missing-host-key-validation | 1 | Real fix —
load_system_host_keys + RejectPolicy |
| cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to
size_t |
## Real fixes (with measurable security improvement)
**SSH host key verification (Go + Python)**
Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with
proper host key verification against a known_hosts file (configurable
via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when
unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()`
so existing setups keep working.
**SQL injection in `user_canvas`**
Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause`
helper. Both `GetList()` and `ListByTenantIDs()` now route the
user-supplied `orderby` query param through the helper, defaulting to
`create_time` on miss.
**SQL injection in `pipeline_operation_log`**
Existing whitelist documented via CodeQL comment.
**Real SQL injection in `infinity/chunk.go:931`**
Escape `'` → `''` on user-controlled `questionText` before splicing into
`filter_fulltext(...)` SQL filter.
**Real SQL injection in `elasticsearch/sql.go:75`**
Defense-in-depth escape on tokenizer output before splicing into
`MATCH(...)`.
**Python code injection in `result_protocol.go`**
Replace raw JSON literal embedding into Python/JS expressions with
base64 + `json.loads` / `JSON.parse(Buffer.from(...,
'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink
and the brittleness of mixing JSON true/false/null with Python syntax.
**URL substring check bypass in `embedding_model.py`**
Replace `if "dashscope-intl.aliyuncs.com" in u` with
`urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url
like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot
bypass the routing.
**Prototype pollution in `setNestedValue` (TS)**
Reject `__proto__`/`constructor`/`prototype` keys before any assignment.
**Integer overflow**
- scrypt params via `ParseInt` + non-positive check
(`internal/common/password.go`)
- `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go)
- `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc)
**Cookie httponly**
Set explicitly with rationale: this is the OAuth bootstrap cookie
intentionally read by the SPA.
**Stack trace exposure**
Replace `error.message` in HTTP 500 response with generic `"internal
error"`; full error still logged server-side via `console.error`.
**Weak hashing**
MD5 → SHA-256 for deterministic `conv_id` derivation
(`conversation_service.py`).
**Log scrubbing**
Remove or redact user-controlled / sensitive content from clear-text
logs across 8 ingestion parsers, `llm_service.py` ×11,
`tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10,
`conftest.py` ×4, `init_data.py`, `dataset_api_service.py`,
`generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`,
`pdf_parser.go`. Most patterns converted to parameterized logging
(`logging.info("...: %d", n)`) or static messages.
## CodeQL suppressions (each with rationale)
For alerts where the data flow is genuinely safe but CodeQL can't see
the context — operator-controlled URLs, sanitized inputs, etc. — I added
`// codeql[go/<rule>] <rationale>` annotations rather than dismissing
them, so future readers can audit the rationale inline:
- `internal/agent/component/invoke.go:135` — Invoke is a generic canvas
HTTP client
- `internal/service/langfuse.go` ×2 — host is per-tenant operator config
- `internal/service/file.go:1184` — already SSRF-guarded by
`assertURLSafe`
- `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` +
IP-pinned
- `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't
be tampered
- `internal/service/deep_researcher.go:269` — `callback` is SSE display
string, not SQL
- `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC
4122)
- `internal/cli/common_command.go` ×2 — CLI trusts operator-configured
URL
- `internal/utility/smtp.go:194` — msg is server-built, not user form
input
- `internal/entity/models/*` ×14 (path-injection) — audio file paths are
caller-supplied
## Test plan
- ✅ All 13 modified Go packages build cleanly
- ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`,
`internal/agent/component`, `internal/engine/infinity`, `internal/dao`
- ✅ All 11 modified Python files parse via `ast.parse`
- ✅ TypeScript `tsc --noEmit` clean on the modified
`use-provider-fields.tsx`
- ✅ `node --check` clean on the modified JS file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
|
|
|
// Log the full error server-side for debugging, but return a
|
|
|
|
|
// generic message to the client — error.message can leak
|
|
|
|
|
// filesystem paths, internal hostnames, library internals, etc.
|
2026-06-23 17:45:31 +08:00
|
|
|
console.error(error);
|
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407)
## Summary
Resolves all 93 open alerts at
https://github.com/infiniflow/ragflow/security/code-scanning by rule:
| Rule | Count | Treatment |
|------|-------|-----------|
| py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing |
| go/path-injection | 15 | Real fix where possible, suppression with
rationale |
| go/request-forgery | 8 | Suppression with rationale
(operator-controlled URLs) |
| go/clear-text-logging | 10 | Real fix — log scrubbing |
| go/unsafe-quoting | 5 | Real fix — escape or refactor |
| go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment |
| go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 |
| go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range
check |
| go/insecure-hostkeycallback | 1 | Real fix — known_hosts file |
| go/disabled-certificate-check | 2 | Suppression with rationale |
| go/command-injection | 1 | Suppression (sanitized via shq()) |
| go/email-injection | 1 | Suppression with rationale |
| go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) |
| js/stack-trace-exposure | 1 | Real fix — generic client message |
| js/prototype-pollution-utility | 1 | Real fix — reject
__proto__/constructor/prototype |
| py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 |
| py/incomplete-url-substring-sanitization | 3 | Real fix —
urlparse(hostname) |
| py/paramiko-missing-host-key-validation | 1 | Real fix —
load_system_host_keys + RejectPolicy |
| cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to
size_t |
## Real fixes (with measurable security improvement)
**SSH host key verification (Go + Python)**
Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with
proper host key verification against a known_hosts file (configurable
via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when
unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()`
so existing setups keep working.
**SQL injection in `user_canvas`**
Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause`
helper. Both `GetList()` and `ListByTenantIDs()` now route the
user-supplied `orderby` query param through the helper, defaulting to
`create_time` on miss.
**SQL injection in `pipeline_operation_log`**
Existing whitelist documented via CodeQL comment.
**Real SQL injection in `infinity/chunk.go:931`**
Escape `'` → `''` on user-controlled `questionText` before splicing into
`filter_fulltext(...)` SQL filter.
**Real SQL injection in `elasticsearch/sql.go:75`**
Defense-in-depth escape on tokenizer output before splicing into
`MATCH(...)`.
**Python code injection in `result_protocol.go`**
Replace raw JSON literal embedding into Python/JS expressions with
base64 + `json.loads` / `JSON.parse(Buffer.from(...,
'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink
and the brittleness of mixing JSON true/false/null with Python syntax.
**URL substring check bypass in `embedding_model.py`**
Replace `if "dashscope-intl.aliyuncs.com" in u` with
`urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url
like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot
bypass the routing.
**Prototype pollution in `setNestedValue` (TS)**
Reject `__proto__`/`constructor`/`prototype` keys before any assignment.
**Integer overflow**
- scrypt params via `ParseInt` + non-positive check
(`internal/common/password.go`)
- `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go)
- `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc)
**Cookie httponly**
Set explicitly with rationale: this is the OAuth bootstrap cookie
intentionally read by the SPA.
**Stack trace exposure**
Replace `error.message` in HTTP 500 response with generic `"internal
error"`; full error still logged server-side via `console.error`.
**Weak hashing**
MD5 → SHA-256 for deterministic `conv_id` derivation
(`conversation_service.py`).
**Log scrubbing**
Remove or redact user-controlled / sensitive content from clear-text
logs across 8 ingestion parsers, `llm_service.py` ×11,
`tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10,
`conftest.py` ×4, `init_data.py`, `dataset_api_service.py`,
`generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`,
`pdf_parser.go`. Most patterns converted to parameterized logging
(`logging.info("...: %d", n)`) or static messages.
## CodeQL suppressions (each with rationale)
For alerts where the data flow is genuinely safe but CodeQL can't see
the context — operator-controlled URLs, sanitized inputs, etc. — I added
`// codeql[go/<rule>] <rationale>` annotations rather than dismissing
them, so future readers can audit the rationale inline:
- `internal/agent/component/invoke.go:135` — Invoke is a generic canvas
HTTP client
- `internal/service/langfuse.go` ×2 — host is per-tenant operator config
- `internal/service/file.go:1184` — already SSRF-guarded by
`assertURLSafe`
- `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` +
IP-pinned
- `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't
be tampered
- `internal/service/deep_researcher.go:269` — `callback` is SSE display
string, not SQL
- `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC
4122)
- `internal/cli/common_command.go` ×2 — CLI trusts operator-configured
URL
- `internal/utility/smtp.go:194` — msg is server-built, not user form
input
- `internal/entity/models/*` ×14 (path-injection) — audio file paths are
caller-supplied
## Test plan
- ✅ All 13 modified Go packages build cleanly
- ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`,
`internal/agent/component`, `internal/engine/infinity`, `internal/dao`
- ✅ All 11 modified Python files parse via `ast.parse`
- ✅ TypeScript `tsc --noEmit` clean on the modified
`use-provider-fields.tsx`
- ✅ `node --check` clean on the modified JS file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
|
|
|
return sendJson(res, 500, { code: 500, message: 'internal error', data: null });
|
2026-06-23 17:45:31 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.on('upgrade', (req, socket) => {
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
|
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
|
|
|
if (parts[0] !== 'whatsapp' || parts.length !== 4 || parts[2] !== 'events' || parts[3] !== 'ws') {
|
|
|
|
|
socket.destroy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!isAuthorized(req)) {
|
|
|
|
|
socket.destroy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const sessionKey = decodeURIComponent(parts[1]);
|
|
|
|
|
const key = req.headers['sec-websocket-key'];
|
|
|
|
|
if (!key) {
|
|
|
|
|
socket.destroy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const accept = crypto.createHash('sha1').update(`${key}${WS_MAGIC}`).digest('base64');
|
|
|
|
|
socket.write(
|
|
|
|
|
[
|
|
|
|
|
'HTTP/1.1 101 Switching Protocols',
|
|
|
|
|
'Upgrade: websocket',
|
|
|
|
|
'Connection: Upgrade',
|
|
|
|
|
`Sec-WebSocket-Accept: ${accept}`,
|
|
|
|
|
'',
|
|
|
|
|
'',
|
|
|
|
|
].join('\r\n'),
|
|
|
|
|
);
|
|
|
|
|
socket.setNoDelay(true);
|
|
|
|
|
const session = getExistingSession(sessionKey);
|
|
|
|
|
if (!session) {
|
|
|
|
|
socket.destroy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const after = Number.parseInt(url.searchParams.get('after') || '0', 10) || 0;
|
|
|
|
|
session.addSubscriber(socket, after);
|
|
|
|
|
socket.on('end', () => {
|
|
|
|
|
session.subscribers.forEach((subscriber) => {
|
|
|
|
|
if (subscriber.socket === socket) {
|
|
|
|
|
session.subscribers.delete(subscriber);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
socket.destroy();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
|
|
|
console.log(`WhatsApp gateway listening on http://${HOST}:${PORT}`);
|
|
|
|
|
});
|