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
82 changes: 72 additions & 10 deletions crates/openab-core/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,28 @@ pub struct GatewayEventContext {
///
/// This is the core event-handling logic extracted from the WebSocket handler,
/// made available for the unified binary to call directly from axum webhook handlers.
/// Throttle for request-access echoes: at most one echo per (platform, sender)
/// per [`ECHO_WINDOW`], to prevent an untrusted spammer from being amplified by
/// the bot's replies.
static ECHO_THROTTLE: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));

const ECHO_WINDOW: std::time::Duration = std::time::Duration::from_secs(300);

/// Returns true if an echo to `key` is allowed now (and records the timestamp).
fn echo_allowed(key: &str) -> bool {
let now = std::time::Instant::now();
let mut map = ECHO_THROTTLE.lock().unwrap();
match map.get(key) {
Some(prev) if now.duration_since(*prev) < ECHO_WINDOW => false,
_ => {
map.insert(key.to_string(), now);
true
}
}
}

pub async fn process_gateway_event(
event_json: &str,
ctx: &GatewayEventContext,
Expand Down Expand Up @@ -1174,16 +1196,47 @@ pub async fn process_gateway_event(
let decision =
ctx.router
.gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id);
if !decision.is_allowed() {
tracing::info!(
platform = %event.platform,
sender = %event.sender.id,
channel = %event.channel.id,
?decision,
"gateway event denied by trust gate"
);
// Phase 2 will echo the sender their ID on Decision::DenyIdentity.
return Ok(false);
match decision {
crate::trust::Decision::Allow => {}
crate::trust::Decision::DenyIdentity => {
// L3 identity deny → echo the sender their ID so they can request
// access (throttled to avoid amplification). Bots never reach here
// (should_skip_event handles bot admission; L3 is human-only).
tracing::info!(
platform = %event.platform,
sender = %event.sender.id,
channel = %event.channel.id,
"gateway event denied (identity); echoing request-access"
);
let throttle_key = format!("{}:{}", event.platform, event.sender.id);
if echo_allowed(&throttle_key) {
let echo_channel = ChannelRef {
platform: event.platform.clone(),
channel_id: event.channel.id.clone(),
thread_id: event.channel.thread_id.clone(),
parent_id: None,
origin_event_id: Some(event.event_id.clone()),
};
let echo = format!(
"⚠️ You are not on this bot's trusted list.\nYour ID: {}\nAsk the admin to add it to allowed_users.",
event.sender.id
);
let _ = ctx.adapter.send_message(&echo_channel, &echo).await;
}
return Ok(false);
}
// DenyScope (and any future variant) → silent drop (scope is not a
// security boundary; no request-access echo).
_ => {
tracing::info!(
platform = %event.platform,
sender = %event.sender.id,
channel = %event.channel.id,
?decision,
"gateway event denied (scope); silent"
);
return Ok(false);
}
}

tracing::info!(
Expand Down Expand Up @@ -1420,6 +1473,15 @@ mod tests {
use super::*;
use std::collections::HashSet;

#[test]
fn echo_allowed_throttles_repeat_within_window() {
// Unique key so we don't collide with other tests touching the global map.
let key = "test-platform:test-sender-echo-throttle";
assert!(echo_allowed(key), "first echo should be allowed");
assert!(!echo_allowed(key), "immediate repeat should be throttled");
assert!(!echo_allowed(key), "still throttled within the window");
}

fn make_event(is_bot: bool, sender_id: &str, channel_id: &str, channel_type: &str, thread_id: Option<&str>, mentions: Vec<&str>) -> GatewayEvent {
serde_json::from_value(serde_json::json!({
"schema": "openab.gateway.event.v1",
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ async fn main() -> anyhow::Result<()> {
};
let allow_all_channels = env_bool("GATEWAY_ALLOW_ALL_CHANNELS", true);
let allowed_channels = env_set("GATEWAY_ALLOWED_CHANNELS");
let allow_all_users = env_bool("GATEWAY_ALLOW_ALL_USERS", true);
// L3 identity: trust-none by default (Phase 3). Was `true` in #1267
// (behavior-preserving); now defaults deny-all — set GATEWAY_ALLOW_ALL_USERS=true
// or list GATEWAY_ALLOWED_USERS to admit senders. L2 (channels) stays open.
let allow_all_users = env_bool("GATEWAY_ALLOW_ALL_USERS", false);
let allowed_users = env_set("GATEWAY_ALLOWED_USERS");
let mut reg = PlatformTrustConfigs::new();
for platform in ["telegram", "line", "feishu", "wecom", "googlechat", "teams"] {
Expand Down
Loading