Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 96 additions & 0 deletions src-tauri/src/chat_channel/authz.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Inbound sender authorization for chat channels.
//!
//! Chat commands can spawn and drive real agents (`/task`, `/approve`, …) with the
//! host's full privileges, so an un-vetted inbound message is equivalent to remote
//! code execution. Every inbound command is therefore gated on a per-channel
//! allowlist of sender ids, stored in the channel `config_json` under
//! `allowed_senders` (an array of strings).
//!
//! The gate is **fail-closed**: a missing key, an empty list, or an unparseable
//! config authorizes nobody. Operators add their sender id (surfaced to them in the
//! "unauthorized" reply) via the channel settings before the bot will act.

use serde_json::Value;

/// Extract and normalize the `allowed_senders` list from a channel `config_json`.
/// Entries are trimmed; blanks are dropped. Returns empty on any parse failure.
fn allowed_senders(config_json: &str) -> Vec<String> {
serde_json::from_str::<Value>(config_json)
.ok()
.as_ref()
.and_then(|v| v.get("allowed_senders"))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}

/// Whether `sender_id` is permitted to drive the channel.
///
/// Fail-closed: an empty/absent allowlist or a blank sender authorizes no one.
pub fn is_sender_allowed(config_json: &str, sender_id: &str) -> bool {
let sender = sender_id.trim();
if sender.is_empty() {
return false;
}
allowed_senders(config_json)
.iter()
.any(|allowed| allowed == sender)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn empty_or_missing_allowlist_denies_everyone() {
assert!(!is_sender_allowed("{}", "123"));
assert!(!is_sender_allowed(
r#"{"bot_token":"x","chat_id":"y"}"#,
"123"
));
assert!(!is_sender_allowed(r#"{"allowed_senders":[]}"#, "123"));
}

#[test]
fn unparseable_config_denies() {
assert!(!is_sender_allowed("not json", "123"));
assert!(!is_sender_allowed("", "123"));
}

#[test]
fn listed_sender_is_allowed_others_are_not() {
let cfg = r#"{"bot_token":"x","allowed_senders":["123","456"]}"#;
assert!(is_sender_allowed(cfg, "123"));
assert!(is_sender_allowed(cfg, "456"));
assert!(!is_sender_allowed(cfg, "789"));
}

#[test]
fn ids_and_sender_are_trimmed() {
let cfg = r#"{"allowed_senders":[" 123 ","456"]}"#;
assert!(is_sender_allowed(cfg, "123"));
assert!(is_sender_allowed(cfg, " 123 "));
}

#[test]
fn blank_sender_is_denied_even_if_blank_listed() {
// blanks are stripped from the list, so an empty sender never matches
let cfg = r#"{"allowed_senders":[""," "]}"#;
assert!(!is_sender_allowed(cfg, ""));
assert!(!is_sender_allowed(cfg, " "));
}

#[test]
fn non_string_entries_are_ignored() {
let cfg = r#"{"allowed_senders":[123,"456",true,null]}"#;
// numeric 123 is ignored (Telegram ids arrive as strings); only "456" counts
assert!(!is_sender_allowed(cfg, "123"));
assert!(is_sender_allowed(cfg, "456"));
}
}
35 changes: 32 additions & 3 deletions src-tauri/src/chat_channel/command_dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;

use super::authz;
use super::command_handlers;
use super::i18n::{self, Lang};
use super::manager::ChatChannelManager;
use super::session_bridge::SessionBridge;
use super::session_commands;
use super::types::IncomingCommand;
use crate::acp::manager::ConnectionManager;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service};
use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
use crate::web::event_bridge::EventEmitter;

const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
Expand Down Expand Up @@ -68,7 +71,9 @@ pub fn spawn_command_dispatcher(
let text = cmd.command_text.trim();
tracing::info!(
"[ChatChannel] received command from channel={} sender={}: {:?}",
cmd.channel_id, cmd.sender_id, text
cmd.channel_id,
cmd.sender_id,
text
);

// Log inbound command
Expand Down Expand Up @@ -112,7 +117,8 @@ pub fn spawn_command_dispatcher(
Err(e) => {
tracing::error!(
"[ChatChannel] failed to send response for {:?} to channel {}: {e}",
text, cmd.channel_id
text,
cmd.channel_id
);
("failed", Some(e.to_string()))
}
Expand Down Expand Up @@ -145,6 +151,29 @@ async fn dispatch_command(
sender_id: &str,
lang: Lang,
) -> super::types::RichMessage {
// ── Authorization gate (fail-closed) ──
// Chat commands spawn and drive agents with the host's full privileges, so
// every inbound message must come from an allow-listed sender. An empty or
// unset allowlist authorizes no one. We reply with the sender's own id (and
// run no command) so the operator can add it from the channel settings.
let authorized = match chat_channel_service::get_by_id(db, channel_id).await {
Ok(Some(ch)) => authz::is_sender_allowed(&ch.config_json, sender_id),
Ok(None) => false,
Err(e) => {
tracing::error!(
"[ChatChannel] authz lookup failed for channel={channel_id}: {e}; denying"
);
false
}
};
if !authorized {
tracing::warn!(
"[ChatChannel] BLOCKED unauthorized sender={sender_id} on channel={channel_id}: {text:?}"
);
return super::types::RichMessage::error(i18n::unauthorized_sender_body(lang, sender_id))
.with_title(i18n::unauthorized_sender_title(lang));
}

// Strip prefix; if text doesn't start with it, try as follow-up
let without_prefix = match text.strip_prefix(prefix) {
Some(rest) => rest,
Expand Down
52 changes: 52 additions & 0 deletions src-tauri/src/chat_channel/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,58 @@ pub fn unknown_command_title(lang: Lang) -> &'static str {
}
}

pub fn unauthorized_sender_title(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "未授权",
Lang::ZhTw => "未授權",
Lang::Ja => "未承認",
Lang::Ko => "권한 없음",
Lang::Es => "No autorizado",
Lang::De => "Nicht autorisiert",
Lang::Fr => "Non autorisé",
Lang::Pt => "Não autorizado",
Lang::Ar => "غير مصرح",
Lang::En => "Unauthorized",
}
}

/// Reply sent to a sender who is not on the channel allowlist. Includes their own
/// sender id so the operator can add it to the channel's allowed senders.
pub fn unauthorized_sender_body(lang: Lang, sender_id: &str) -> String {
match lang {
Lang::ZhCn => format!(
"你无权使用此机器人。你的发送者 ID 是 `{sender_id}`。请管理员将其加入该频道的允许发送者列表。"
),
Lang::ZhTw => format!(
"你無權使用此機器人。你的發送者 ID 是 `{sender_id}`。請管理員將其加入該頻道的允許發送者清單。"
),
Lang::Ja => format!(
"このボットを使用する権限がありません。あなたの送信者 ID は `{sender_id}` です。管理者にチャンネルの許可リストへの追加を依頼してください。"
),
Lang::Ko => format!(
"이 봇을 사용할 권한이 없습니다. 발신자 ID는 `{sender_id}` 입니다. 관리자에게 채널 허용 목록에 추가를 요청하세요."
),
Lang::Es => format!(
"No tienes autorización para usar este bot. Tu ID de remitente es `{sender_id}`. Pide al administrador que lo añada a los remitentes permitidos del canal."
),
Lang::De => format!(
"Du bist nicht berechtigt, diesen Bot zu nutzen. Deine Sender-ID ist `{sender_id}`. Bitte den Administrator, sie zur Liste der erlaubten Sender des Kanals hinzuzufügen."
),
Lang::Fr => format!(
"Vous n'êtes pas autorisé à utiliser ce bot. Votre identifiant d'expéditeur est `{sender_id}`. Demandez à l'administrateur de l'ajouter aux expéditeurs autorisés du canal."
),
Lang::Pt => format!(
"Você não tem autorização para usar este bot. Seu ID de remetente é `{sender_id}`. Peça ao administrador para adicioná-lo aos remetentes permitidos do canal."
),
Lang::Ar => format!(
"غير مصرح لك باستخدام هذا البوت. معرّف المرسل الخاص بك هو `{sender_id}`. اطلب من المسؤول إضافته إلى قائمة المرسلين المسموح لهم في القناة."
),
Lang::En => format!(
"You are not authorized to use this bot. Your sender ID is `{sender_id}`. Ask the administrator to add it to this channel's allowed senders."
),
}
}

// ── Session command messages ──

// Folder (/folder)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/chat_channel/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod authz;
pub mod backends;
pub mod command_dispatcher;
pub mod command_handlers;
Expand Down
38 changes: 34 additions & 4 deletions src/components/settings/add-chat-channel-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -46,6 +47,7 @@ export function AddChatChannelDialog({
const [chatId, setChatId] = useState("")
const [appId, setAppId] = useState("")
const [baseUrl, setBaseUrl] = useState("https://ilinkai.weixin.qq.com")
const [allowedSenders, setAllowedSenders] = useState("")
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
const [dailyReportTime, setDailyReportTime] = useState("18:00")

Expand All @@ -56,6 +58,7 @@ export function AddChatChannelDialog({
setChatId("")
setAppId("")
setBaseUrl("https://ilinkai.weixin.qq.com")
setAllowedSenders("")
setDailyReportEnabled(false)
setDailyReportTime("18:00")
setError(null)
Expand Down Expand Up @@ -86,12 +89,22 @@ export function AddChatChannelDialog({
setLoading(true)
setError(null)
try {
const configJson =
const allowedSendersArr = allowedSenders
.split("\n")
.map((s: string) => s.trim())
.filter(Boolean)

const baseConfig =
channelType === "weixin"
? JSON.stringify({ base_url: baseUrl })
? { base_url: baseUrl }
: channelType === "lark"
? JSON.stringify({ app_id: appId, chat_id: chatId })
: JSON.stringify({ chat_id: chatId })
? { app_id: appId, chat_id: chatId }
: { chat_id: chatId }

const configJson = JSON.stringify({
...baseConfig,
allowed_senders: allowedSendersArr,
})

const channel = await createChatChannel({
name: name.trim(),
Expand Down Expand Up @@ -121,6 +134,7 @@ export function AddChatChannelDialog({
channelType,
appId,
baseUrl,
allowedSenders,
dailyReportEnabled,
dailyReportTime,
handleOpenChange,
Expand Down Expand Up @@ -208,6 +222,22 @@ export function AddChatChannelDialog({
</p>
)}

<div className="space-y-1.5">
<label className="text-xs font-medium">Allowed Sender IDs</label>
<Textarea
value={allowedSenders}
onChange={(e) => setAllowedSenders(e.target.value)}
placeholder={"123456789\n987654321"}
rows={3}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
One sender ID per line. Only these senders may drive agents via
this channel. Leave empty to block everyone (fail-closed). A
blocked sender is told their own ID so you can add it here.
</p>
</div>

<div className="flex items-center justify-between">
<label className="text-xs font-medium">{t("dailyReport")}</label>
<Switch
Expand Down
41 changes: 37 additions & 4 deletions src/components/settings/edit-chat-channel-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -46,6 +47,11 @@ export function EditChatChannelDialog({
const [chatId, setChatId] = useState(config.chat_id ?? "")
const [appId, setAppId] = useState(config.app_id ?? "")
const [baseUrl] = useState(config.base_url ?? "")
const [allowedSenders, setAllowedSenders] = useState<string>(
Array.isArray(config.allowed_senders)
? config.allowed_senders.join("\n")
: ""
)
const [dailyReportEnabled, setDailyReportEnabled] = useState(
channel.daily_report_enabled
)
Expand Down Expand Up @@ -75,12 +81,22 @@ export function EditChatChannelDialog({
setLoading(true)
setError(null)
try {
const configJson =
const allowedSendersArr = allowedSenders
.split("\n")
.map((s: string) => s.trim())
.filter(Boolean)

const baseConfig =
channel.channel_type === "weixin"
? JSON.stringify({ base_url: baseUrl })
? { base_url: baseUrl }
: channel.channel_type === "lark"
? JSON.stringify({ app_id: appId, chat_id: chatId })
: JSON.stringify({ chat_id: chatId })
? { app_id: appId, chat_id: chatId }
: { chat_id: chatId }

const configJson = JSON.stringify({
...baseConfig,
allowed_senders: allowedSendersArr,
})

await updateChatChannel({
id: channel.id,
Expand Down Expand Up @@ -110,6 +126,7 @@ export function EditChatChannelDialog({
channel,
appId,
baseUrl,
allowedSenders,
dailyReportEnabled,
dailyReportTime,
onOpenChange,
Expand Down Expand Up @@ -185,6 +202,22 @@ export function EditChatChannelDialog({
</div>
)}

<div className="space-y-1.5">
<label className="text-xs font-medium">Allowed Sender IDs</label>
<Textarea
value={allowedSenders}
onChange={(e) => setAllowedSenders(e.target.value)}
placeholder={"123456789\n987654321"}
rows={3}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
One sender ID per line. Only these senders may drive agents via
this channel. Leave empty to block everyone (fail-closed). A
blocked sender is told their own ID so you can add it here.
</p>
</div>

<div className="flex items-center justify-between">
<label className="text-xs font-medium">{t("dailyReport")}</label>
<Switch
Expand Down
Loading