security: correções críticas de segurança e estabilidade

SEGURANÇA:
- auth: XOS, LMS, Quality e /api/tenants agora exigem autenticação (102+ rotas)
- CORS: 7 serviços Python trocam allow_origins=["*"] por APP_URL env var
- credentials: removidas senhas hardcoded de metaset/routes.ts; SESSION_SECRET com warning se ausente
- uvicorn: criados server/__init__.py e server/python/__init__.py para module-style correto
- docker: embeddings_service usa uvicorn module-style como os demais

ESTABILIDADE:
- OpenAI: timeout=30s e maxRetries=3 no cliente Manus
- Frappe: AbortSignal.timeout(30s) em todos os fetches
- PipelineOrchestrator: guard processingMonitors evita execuções sobrepostas no setInterval

DADOS:
- WhatsApp auto-reply config agora persiste no banco (coluna auto_reply_config jsonb)
- Migration 0001_whatsapp_auto_reply_config.sql adicionada

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
Claude 2026-03-13 14:34:51 +00:00
parent b80bd22c83
commit 1ab50d456b
No known key found for this signature in database
24 changed files with 93 additions and 17 deletions

View File

@ -41,7 +41,10 @@ case "$SERVICE_NAME" in
--workers 2 --workers 2
;; ;;
embeddings) embeddings)
exec python server/python/embeddings_service.py exec python -m uvicorn server.python.embeddings_service:app \
--host 0.0.0.0 \
--port "$SERVICE_PORT" \
--workers 1
;; ;;
*) *)
echo "[entrypoint] ERRO: SERVICE_NAME desconhecido: $SERVICE_NAME" echo "[entrypoint] ERRO: SERVICE_NAME desconhecido: $SERVICE_NAME"

View File

@ -0,0 +1 @@
ALTER TABLE "whatsapp_sessions" ADD COLUMN IF NOT EXISTS "auto_reply_config" jsonb;

View File

@ -8,6 +8,13 @@
"when": 1769121114659, "when": 1769121114659,
"tag": "0000_low_tiger_shark", "tag": "0000_low_tiger_shark",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1741824000000,
"tag": "0001_whatsapp_auto_reply_config",
"breakpoints": true
} }
] ]
} }

0
server/__init__.py Normal file
View File

View File

@ -30,8 +30,12 @@ async function comparePasswords(supplied: string, stored: string) {
return timingSafeEqual(hashedBuf, suppliedBuf); return timingSafeEqual(hashedBuf, suppliedBuf);
} }
if (!process.env.SESSION_SECRET) {
console.warn("[auth] WARNING: SESSION_SECRET env var not set. Using insecure fallback. Set SESSION_SECRET in production.");
}
const sessionSettings: session.SessionOptions = { const sessionSettings: session.SessionOptions = {
secret: process.env.SESSION_SECRET || "arcadia-browser-secret-key-2024", secret: process.env.SESSION_SECRET || `arcadia-dev-${Math.random().toString(36)}`,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
store: storage.sessionStore, store: storage.sessionStore,

View File

@ -58,6 +58,7 @@ const ALL_PHASES: PipelinePhase[] = ["design", "codegen", "validation", "staging
class PipelineOrchestrator extends EventEmitter { class PipelineOrchestrator extends EventEmitter {
private activeMonitors: Map<number, NodeJS.Timeout> = new Map(); private activeMonitors: Map<number, NodeJS.Timeout> = new Map();
private processingMonitors: Set<number> = new Set();
async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise<XosDevPipeline> { async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise<XosDevPipeline> {
const correlationId = randomUUID(); const correlationId = randomUUID();
@ -169,10 +170,14 @@ class PipelineOrchestrator extends EventEmitter {
if (this.activeMonitors.has(pipelineId)) return; if (this.activeMonitors.has(pipelineId)) return;
const interval = setInterval(async () => { const interval = setInterval(async () => {
if (this.processingMonitors.has(pipelineId)) return;
this.processingMonitors.add(pipelineId);
try { try {
await this.checkPhaseProgress(pipelineId, mainTaskId); await this.checkPhaseProgress(pipelineId, mainTaskId);
} catch (error) { } catch (error) {
console.error(`[PipelineOrchestrator] Erro no monitor #${pipelineId}:`, error); console.error(`[PipelineOrchestrator] Erro no monitor #${pipelineId}:`, error);
} finally {
this.processingMonitors.delete(pipelineId);
} }
}, 3000); }, 3000);

View File

@ -36,6 +36,7 @@ export class FrappeService {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
}, },
signal: AbortSignal.timeout(30000),
}; };
if (data && (method === "POST" || method === "PUT")) { if (data && (method === "POST" || method === "PUT")) {

View File

@ -4,6 +4,15 @@ import { sql } from "drizzle-orm";
const router = Router(); const router = Router();
function requireAuth(req: any, res: any, next: any) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
router.use(requireAuth);
router.get("/courses", async (req: Request, res: Response) => { router.get("/courses", async (req: Request, res: Response) => {
try { try {
const { category, featured, published } = req.query; const { category, featured, published } = req.query;

View File

@ -12,6 +12,8 @@ import * as erpnextService from "../erpnext/service";
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY, apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL, baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
timeout: 30000,
maxRetries: 3,
}); });
const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, um assistente empresarial inteligente e proativo. const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, um assistente empresarial inteligente e proativo.

View File

@ -4,14 +4,17 @@ import { metasetClient } from "./client";
const METASET_HOST = process.env.METABASE_HOST || "localhost"; const METASET_HOST = process.env.METABASE_HOST || "localhost";
const METASET_PORT = parseInt(process.env.METABASE_PORT || "8088", 10); const METASET_PORT = parseInt(process.env.METABASE_PORT || "8088", 10);
const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`; const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`;
const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL || "admin@arcadia.app"; const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD || "Arcadia2026!BI"; const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD;
export function registerMetaSetRoutes(app: Express): void { export function registerMetaSetRoutes(app: Express): void {
app.get("/api/bi/metaset/autologin", async (req: Request, res: Response) => { app.get("/api/bi/metaset/autologin", async (req: Request, res: Response) => {
try { try {
if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" });
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
return res.status(503).json({ error: "MetaSet credentials not configured (METASET_ADMIN_EMAIL / METASET_ADMIN_PASSWORD)" });
}
const sessionResp = await fetch(`${METASET_URL}/api/session`, { const sessionResp = await fetch(`${METASET_URL}/api/session`, {
method: "POST", method: "POST",

View File

View File

@ -36,7 +36,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -23,7 +23,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -38,7 +38,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -21,7 +21,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -133,7 +133,8 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

@ -56,7 +56,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -20,7 +20,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[os.getenv("APP_URL", "http://localhost:5000")],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -13,6 +13,15 @@ import { eq, desc, and, gte, lte, like, or, sql, isNull } from "drizzle-orm";
const router = Router(); const router = Router();
function requireAuth(req: any, res: any, next: any) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
router.use(requireAuth);
// ========== AMOSTRAS (RF-QC01) ========== // ========== AMOSTRAS (RF-QC01) ==========
router.get("/samples", async (req: Request, res: Response) => { router.get("/samples", async (req: Request, res: Response) => {

View File

@ -136,7 +136,10 @@ export async function registerRoutes(
// Arcádia Plus - SSO routes (proxy already registered at top) // Arcádia Plus - SSO routes (proxy already registered at top)
app.use("/api/plus/sso", plusSsoRoutes); app.use("/api/plus/sso", plusSsoRoutes);
app.get("/api/tenants", async (_req, res) => { app.get("/api/tenants", async (req: any, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Authentication required" });
}
try { try {
const tenants = await storage.getTenants(); const tenants = await storage.getTenants();
res.json(tenants); res.json(tenants);

View File

@ -229,7 +229,7 @@ export function registerWhatsappRoutes(app: Express): void {
const userId = req.user!.id; const userId = req.user!.id;
const config = req.body; const config = req.body;
whatsappService.setAutoReplyConfig(userId, config); await whatsappService.setAutoReplyConfig(userId, config);
res.json({ success: true, config: whatsappService.getAutoReplyConfig(userId) }); res.json({ success: true, config: whatsappService.getAutoReplyConfig(userId) });
} catch (error) { } catch (error) {
console.error("WhatsApp set auto-reply config error:", error); console.error("WhatsApp set auto-reply config error:", error);

View File

@ -9,7 +9,7 @@ import { EventEmitter } from "events";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { db } from "../../db/index"; import { db } from "../../db/index";
import { whatsappContacts, whatsappMessages, whatsappTickets, graphNodes, graphEdges, chatThreads, chatParticipants, chatMessages, pcCrmLeads, tenants } from "@shared/schema"; import { whatsappContacts, whatsappMessages, whatsappTickets, whatsappSessions, graphNodes, graphEdges, chatThreads, chatParticipants, chatMessages, pcCrmLeads, tenants } from "@shared/schema";
import { eq, and, desc, sql } from "drizzle-orm"; import { eq, and, desc, sql } from "drizzle-orm";
import { learningService } from "../learning/service"; import { learningService } from "../learning/service";
import OpenAI from "openai"; import OpenAI from "openai";
@ -57,8 +57,17 @@ class WhatsAppService extends EventEmitter {
} }
} }
setAutoReplyConfig(userId: string, config: Partial<AutoReplyConfig>): void { async setAutoReplyConfig(userId: string, config: Partial<AutoReplyConfig>): Promise<void> {
const existing = this.autoReplyConfigs.get(userId) || { const existing = this.autoReplyConfigs.get(userId) || await this.loadAutoReplyConfig(userId);
const merged = { ...existing, ...config };
this.autoReplyConfigs.set(userId, merged);
await db.update(whatsappSessions)
.set({ autoReplyConfig: merged })
.where(eq(whatsappSessions.userId, userId));
}
async loadAutoReplyConfig(userId: string): Promise<AutoReplyConfig> {
const defaults: AutoReplyConfig = {
enabled: false, enabled: false,
welcomeMessage: "Olá! Obrigado por entrar em contato. Em breve um atendente irá te responder.", welcomeMessage: "Olá! Obrigado por entrar em contato. Em breve um atendente irá te responder.",
businessHours: { start: 8, end: 18 }, businessHours: { start: 8, end: 18 },
@ -66,7 +75,16 @@ class WhatsAppService extends EventEmitter {
aiEnabled: true, aiEnabled: true,
maxAutoRepliesPerContact: 3, maxAutoRepliesPerContact: 3,
}; };
this.autoReplyConfigs.set(userId, { ...existing, ...config }); try {
const [session] = await db.select({ autoReplyConfig: whatsappSessions.autoReplyConfig })
.from(whatsappSessions)
.where(eq(whatsappSessions.userId, userId))
.limit(1);
if (session?.autoReplyConfig) {
return { ...defaults, ...(session.autoReplyConfig as Partial<AutoReplyConfig>) };
}
} catch {}
return defaults;
} }
getAutoReplyConfig(userId: string): AutoReplyConfig { getAutoReplyConfig(userId: string): AutoReplyConfig {

View File

@ -4,6 +4,15 @@ import { sql } from "drizzle-orm";
const router = Router(); const router = Router();
function requireAuth(req: any, res: any, next: any) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
router.use(requireAuth);
// ========== CONTACTS ========== // ========== CONTACTS ==========
router.get("/contacts", async (req: Request, res: Response) => { router.get("/contacts", async (req: Request, res: Response) => {

View File

@ -443,6 +443,7 @@ export const whatsappSessions = pgTable("whatsapp_sessions", {
status: text("status").default("disconnected"), status: text("status").default("disconnected"),
phoneNumber: text("phone_number"), phoneNumber: text("phone_number"),
lastSync: timestamp("last_sync"), lastSync: timestamp("last_sync"),
autoReplyConfig: jsonb("auto_reply_config").$type<Record<string, any>>(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(), createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
}); });