diff --git a/apps/android/app/src/main/java/bot/molt/android/NodeGatewaySync.kt b/apps/android/app/src/main/java/bot/molt/android/NodeGatewaySync.kt index e35394ce6..430d724aa 100644 --- a/apps/android/app/src/main/java/bot/molt/android/NodeGatewaySync.kt +++ b/apps/android/app/src/main/java/bot/molt/android/NodeGatewaySync.kt @@ -264,15 +264,6 @@ internal fun parseHexColorArgb(raw: String?): Long? { return 0xFF000000L or rgb } -internal fun normalizeMainKey(raw: String?): String { - val trimmed = raw?.trim().orEmpty() - return trimmed.ifEmpty { "main" } -} - -internal fun isCanonicalMainSessionKey(key: String): Boolean { - return key.trim().equals("main", ignoreCase = true) -} - // MARK: - JSON Helpers internal fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt b/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt index e64051649..a9804ee66 100644 --- a/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt +++ b/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt @@ -1,13 +1,11 @@ package bot.molt.android internal fun normalizeMainKey(raw: String?): String { - val trimmed = raw?.trim() - return if (!trimmed.isNullOrEmpty()) trimmed else "main" + val trimmed = raw?.trim().orEmpty() + return trimmed.ifEmpty { "main" } } internal fun isCanonicalMainSessionKey(raw: String?): Boolean { val trimmed = raw?.trim().orEmpty() - if (trimmed.isEmpty()) return false - if (trimmed == "global") return true - return trimmed.startsWith("agent:") + return trimmed.equals("main", ignoreCase = true) } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 2e19a17eb..85515700f 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -438,7 +438,7 @@ final class NodeAppModel { var text: String var sessionKey: String? } - let payload = Payload(text: text, sessionKey: sessionKey) + let payload = Payload(text: text, sessionKey: key) let data = try JSONEncoder().encode(payload) guard let json = String(bytes: data, encoding: .utf8) else { throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ diff --git a/apps/ios/Sources/SessionKey.swift b/apps/ios/Sources/SessionKey.swift index bac73f670..a76df2fb7 100644 --- a/apps/ios/Sources/SessionKey.swift +++ b/apps/ios/Sources/SessionKey.swift @@ -8,8 +8,6 @@ enum SessionKey { static func isCanonicalMainSessionKey(_ value: String?) -> Bool { let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return false } - if trimmed == "global" { return true } - return trimmed.hasPrefix("agent:") + return trimmed.caseInsensitiveCompare("main") == .orderedSame } } diff --git a/src/gateway/protocol/schema/error-codes.ts b/src/gateway/protocol/schema/error-codes.ts index 37e002a79..21dbb402a 100644 --- a/src/gateway/protocol/schema/error-codes.ts +++ b/src/gateway/protocol/schema/error-codes.ts @@ -6,6 +6,7 @@ export const ErrorCodes = { AGENT_TIMEOUT: "AGENT_TIMEOUT", INVALID_REQUEST: "INVALID_REQUEST", UNAVAILABLE: "UNAVAILABLE", + TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS", } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9010a6f21..ce27f372f 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -39,7 +39,8 @@ import { readSessionMessages, resolveSessionModelRef, } from "../session-utils.js"; -import { stripEnvelopeFromMessages } from "../chat-sanitize.js"; +import { sanitizeIncomingMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js"; +import { canAccessAgent } from "../../security/rbac.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -362,7 +363,25 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } + + // Sanitize incoming message for injection attacks const { cfg, entry } = loadSessionEntry(p.sessionKey); + const sanitized = sanitizeIncomingMessage(parsedMessage, { + stripEnvelopes: true, + blockCritical: false, // Default: warn but don't block critical injection attempts + logAttempts: true, + useBoundaries: true, + }); + if (sanitized.blocked) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "message blocked by security policy"), + ); + return; + } + parsedMessage = sanitized.text; + const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -465,6 +484,22 @@ export const chatHandlers: GatewayRequestHandlers = { sessionKey: p.sessionKey, config: cfg, }); + + // RBAC: Check if sender can access this agent + const senderId = clientInfo?.id ?? "anonymous"; + const agentAccessCheck = canAccessAgent(senderId, agentId ?? "default", cfg); + if (!agentAccessCheck.allowed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `RBAC denied: ${agentAccessCheck.reason ?? "agent access not allowed"}`, + ), + ); + return; + } + let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, agentId), }; @@ -613,13 +648,29 @@ export const chatHandlers: GatewayRequestHandlers = { }; // Load session to find transcript file - const { storePath, entry } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry } = loadSessionEntry(p.sessionKey); const sessionId = entry?.sessionId; if (!sessionId || !storePath) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found")); return; } + // Sanitize injected message + const sanitized = sanitizeIncomingMessage(p.message, { + stripEnvelopes: false, // Don't strip envelopes from injected content + blockCritical: false, // Default: warn but don't block critical injection attempts + logAttempts: true, + useBoundaries: false, // Don't wrap injected assistant messages + }); + if (sanitized.blocked) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "message blocked by security policy"), + ); + return; + } + // Resolve transcript path const transcriptPath = entry?.sessionFile ? entry.sessionFile diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 1d3adbb6f..7be5ac562 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -115,6 +115,21 @@ export const sendHandlers: GatewayRequestHandlers = { return; } + // Rate limit: check channel message rate + const channelKey = accountId ? `${channel}:${accountId}` : channel; + const channelRateCheck = context.rateLimiter.checkChannelMessage(channelKey); + if (!channelRateCheck.allowed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.TOO_MANY_REQUESTS, + `channel rate limit exceeded: retry after ${channelRateCheck.retryAfterMs ?? 1000}ms`, + ), + ); + return; + } + const work = (async (): Promise => { try { const cfg = loadConfig(); diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index c23459a2d..1845dfd9b 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -6,6 +6,7 @@ import type { WizardSession } from "../../wizard/session.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; import type { NodeRegistry } from "../node-registry.js"; import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js"; +import type { GatewayRateLimiter } from "../rate-limit.js"; import type { ChannelRuntimeSnapshot } from "../server-channels.js"; import type { DedupeEntry } from "../server-shared.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -84,6 +85,7 @@ export type GatewayRequestContext = { prompter: import("../../wizard/prompts.js").WizardPrompter, ) => Promise; broadcastVoiceWakeChanged: (triggers: string[]) => void; + rateLimiter: GatewayRateLimiter; }; export type GatewayRequestOptions = { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 34388c697..bc6c483f0 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -478,6 +478,7 @@ export async function startGatewayServer( markChannelLoggedOut, wizardRunner, broadcastVoiceWakeChanged, + rateLimiter, }, }); logGatewayStartup({ diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index b03045eec..c45f02845 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -614,6 +614,10 @@ export function attachGatewayWsMessageHandler(params: { reason: authResult.reason ?? "unauthorized", }); + // Rate limit: record auth failure for exponential backoff + const authClientId = clientIp ?? remoteAddr ?? "unknown"; + buildRequestContext().rateLimiter.recordAuthFailure(authClientId); + setCloseCause("unauthorized", { authMode: resolvedAuth.mode, authProvided, @@ -780,6 +784,10 @@ export function attachGatewayWsMessageHandler(params: { method: authMethod, }); + // Rate limit: clear auth failure state on successful login + const authSuccessClientId = clientIp ?? remoteAddr ?? "unknown"; + buildRequestContext().rateLimiter.clearAuthFailure(authSuccessClientId); + if (isWebchatConnect(connectParams)) { logWsControl.info( `webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, @@ -943,13 +951,31 @@ export function attachGatewayWsMessageHandler(params: { }; void (async () => { + // Rate limit: check if request is allowed + const context = buildRequestContext(); + const rateLimitClientId = + client?.connect.auth?.token?.slice(0, 16) ?? remoteAddr ?? "unknown"; + const isAuthenticated = !!client?.connect.auth?.token; + const rateCheck = context.rateLimiter.checkRequest(rateLimitClientId, isAuthenticated); + if (!rateCheck.allowed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.TOO_MANY_REQUESTS, + `rate limit exceeded: retry after ${rateCheck.retryAfterMs ?? 1000}ms`, + ), + ); + return; + } + await handleGatewayRequest({ req, respond, client, isWebchatConnect, extraHandlers, - context: buildRequestContext(), + context, }); })().catch((err) => { logGateway.error(`request handler failed: ${formatForLog(err)}`); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index e31ad3de3..7627e887a 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -24,6 +24,8 @@ import { type ExecAllowlistEntry, type ExecCommandSegment, } from "../infra/exec-approvals.js"; +import { evaluateBlocklist } from "../infra/exec-blocklist.js"; +import { canExecuteCommand, isRbacEnabled } from "../security/rbac.js"; import { requestExecHostViaSocket, type ExecHostRequest, @@ -822,6 +824,33 @@ async function handleInvoke( const autoAllowSkills = approvals.agent.autoAllowSkills; const sessionKey = params.sessionKey?.trim() || "node"; const runId = params.runId?.trim() || crypto.randomUUID(); + + // RBAC: Check if command execution is allowed for this session + if (isRbacEnabled(cfg)) { + const execCheck = canExecuteCommand(sessionKey, cmdText, cfg); + if (!execCheck.allowed) { + await sendNodeEvent( + client, + "exec.denied", + buildExecEventPayload({ + sessionKey, + runId, + host: "node", + command: cmdText, + reason: `rbac:${execCheck.reason ?? "denied"}`, + }), + ); + await sendInvokeResult(client, frame, { + ok: false, + error: { + code: "RBAC_DENIED", + message: `RBAC denied: ${execCheck.reason ?? "command execution not allowed"}`, + }, + }); + return; + } + } + const env = sanitizeEnv(params.env ?? undefined); const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); const bins = autoAllowSkills ? await skillBins.current() : new Set(); @@ -846,6 +875,32 @@ async function handleInvoke( segments = allowlistEval.segments; } else { const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env }); + + // SECURITY: Check blocklist for argv path (matches rawCommand path behavior) + const cmdForBlocklist = argv.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" "); + const blocklistResult = evaluateBlocklist(cmdForBlocklist); + if (blocklistResult.blocked) { + await sendNodeEvent( + client, + "exec.denied", + buildExecEventPayload({ + sessionKey, + runId, + host: "node", + command: cmdText, + reason: blocklistResult.reason ?? "blocklist", + }), + ); + await sendInvokeResult(client, frame, { + ok: false, + error: { + code: "BLOCKLIST", + message: blocklistResult.reason ?? "command blocked by security policy", + }, + }); + return; + } + const allowlistEval = evaluateExecAllowlist({ analysis, allowlist: approvals.allowlist, diff --git a/src/web/auth-store.ts b/src/web/auth-store.ts index 3a3359793..f8954c4c6 100644 --- a/src/web/auth-store.ts +++ b/src/web/auth-store.ts @@ -84,13 +84,39 @@ export function maybeRestoreCredsFromBackup(authDir: string): void { return; } + // Try plaintext backup first const backupRaw = readCredsJsonRaw(backupPath); - if (!backupRaw) return; + if (backupRaw) { + // Ensure backup is parseable before restoring. + JSON.parse(backupRaw); + // Check if backup is encrypted (.bak.enc exists) or plaintext (.bak exists) + const encBackupPath = resolveWebCredsBackupEncryptedPath(authDir); + if (fsSync.existsSync(encBackupPath)) { + // Encrypted backup -> restore to encrypted creds + const encCredsPath = resolveWebCredsEncryptedPath(authDir); + fsSync.copyFileSync(encBackupPath, encCredsPath); + logger.warn( + { credsPath: encCredsPath }, + "restored WhatsApp creds.json.enc from encrypted backup", + ); + } else if (fsSync.existsSync(backupPath)) { + // Plaintext backup -> restore to plaintext creds + fsSync.copyFileSync(backupPath, credsPath); + logger.warn({ credsPath }, "restored WhatsApp creds.json from backup"); + } + return; + } - // Ensure backup is parseable before restoring. - JSON.parse(backupRaw); - fsSync.copyFileSync(backupPath, credsPath); - logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup"); + // If readCredsJsonRaw returned null but encrypted backup exists, try direct copy + const encBackupPath = resolveWebCredsBackupEncryptedPath(authDir); + if (fsSync.existsSync(encBackupPath)) { + const encCredsPath = resolveWebCredsEncryptedPath(authDir); + fsSync.copyFileSync(encBackupPath, encCredsPath); + logger.warn( + { credsPath: encCredsPath }, + "restored WhatsApp creds.json.enc from encrypted backup", + ); + } } catch { // ignore }