chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
//! Configuration management for the MCP Bash Server
|
||||
//!
|
||||
//! This module handles reading and parsing configuration from TOML files,
|
||||
//! including server settings and security blacklists for command validation.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
pub settings: Settings,
|
||||
pub blacklist: Blacklist,
|
||||
pub whitelist: Whitelist,
|
||||
}
|
||||
|
||||
/// Security whitelist configuration for command validation
|
||||
/// Contains lists of allowed commands and operations
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Whitelist {
|
||||
/// List of command names that are allowed to be executed
|
||||
pub commands: Vec<String>,
|
||||
/// List of regular expressions for commands that are allowed
|
||||
/// These patterns are matched against the full command line
|
||||
pub regex: Vec<String>,
|
||||
}
|
||||
|
||||
/// Security blacklist configuration for command validation
|
||||
/// Contains lists of forbidden commands and regex patterns
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Blacklist {
|
||||
/// List of command names that are not allowed to be executed
|
||||
pub commands: Vec<String>,
|
||||
/// List of regular expressions for commands that are not allowed
|
||||
/// These patterns are matched against the full command line
|
||||
pub regex: Vec<String>,
|
||||
}
|
||||
|
||||
/// Server runtime settings including network configuration
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Settings {
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub env: Option<String>, // "development" or "production"
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read and parse configuration from a TOML file
|
||||
/// Returns parsed Config structure or error if file cannot be read/parsed
|
||||
pub fn read_config(file: &str) -> Result<Config> {
|
||||
let toml_str = fs::read_to_string(file)?;
|
||||
let config: Config = toml::from_str(&toml_str)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_config_parsing() {
|
||||
let config_content = r#"
|
||||
[settings]
|
||||
port = 4000
|
||||
host = "127.0.0.1"
|
||||
env = "development"
|
||||
|
||||
[whitelist]
|
||||
commands = ["echo hello", "ls -la"]
|
||||
regex = ["echo.*", "ls.*"]
|
||||
|
||||
[blacklist]
|
||||
commands = ["rm", "dd"]
|
||||
regex = [".*[|&].*", "^sudo .*"]
|
||||
"#;
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
write!(temp_file, "{config_content}").unwrap();
|
||||
let temp_path = temp_file.path().to_str().unwrap();
|
||||
|
||||
let config = Config::read_config(temp_path).unwrap();
|
||||
|
||||
assert_eq!(config.settings.port, 4000);
|
||||
assert_eq!(config.settings.host, "127.0.0.1");
|
||||
assert_eq!(config.settings.env, Some("development".to_string()));
|
||||
|
||||
assert_eq!(config.whitelist.commands.len(), 2);
|
||||
assert!(
|
||||
config
|
||||
.whitelist
|
||||
.commands
|
||||
.contains(&"echo hello".to_string())
|
||||
);
|
||||
assert!(config.whitelist.commands.contains(&"ls -la".to_string()));
|
||||
|
||||
assert_eq!(config.whitelist.regex.len(), 2);
|
||||
assert!(config.whitelist.regex.contains(&"echo.*".to_string()));
|
||||
assert!(config.whitelist.regex.contains(&"ls.*".to_string()));
|
||||
|
||||
assert_eq!(config.blacklist.commands.len(), 2);
|
||||
assert!(config.blacklist.commands.contains(&"rm".to_string()));
|
||||
assert!(config.blacklist.commands.contains(&"dd".to_string()));
|
||||
|
||||
assert_eq!(config.blacklist.regex.len(), 2);
|
||||
assert!(config.blacklist.regex.contains(&".*[|&].*".to_string()));
|
||||
assert!(config.blacklist.regex.contains(&"^sudo .*".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_invalid_file() {
|
||||
let result = Config::read_config("non_existent_file.toml");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_invalid_toml() {
|
||||
let invalid_config = r#"
|
||||
[settings
|
||||
port = 4000
|
||||
"#;
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
write!(temp_file, "{invalid_config}").unwrap();
|
||||
let temp_path = temp_file.path().to_str().unwrap();
|
||||
|
||||
let result = Config::read_config(temp_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_creation() {
|
||||
let commands = vec!["echo".to_string(), "ls".to_string()];
|
||||
let regex = vec!["test.*".to_string()];
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: commands.clone(),
|
||||
regex: regex.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(whitelist.commands, commands);
|
||||
assert_eq!(whitelist.regex, regex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_creation() {
|
||||
let commands = vec!["rm".to_string(), "dd".to_string()];
|
||||
let regex = vec![".*[|&].*".to_string(), "^sudo .*".to_string()];
|
||||
|
||||
let blacklist = Blacklist {
|
||||
commands: commands.clone(),
|
||||
regex: regex.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(blacklist.commands, commands);
|
||||
assert_eq!(blacklist.regex, regex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_creation() {
|
||||
let settings = Settings {
|
||||
port: 8080,
|
||||
host: "localhost".to_string(),
|
||||
env: Some("production".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(settings.port, 8080);
|
||||
assert_eq!(settings.host, "localhost");
|
||||
assert_eq!(settings.env, Some("production".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
//! Common utilities and shared components for the MCP Bash Server
|
||||
//!
|
||||
//! This module contains the core functionality including:
|
||||
//! - Bash command execution server implementation
|
||||
//! - Configuration management
|
||||
//! - OAuth2 authentication system
|
||||
//! - Command validation and security enforcement
|
||||
|
||||
pub mod bash_server;
|
||||
pub mod config;
|
||||
pub mod oauth;
|
||||
pub mod validator;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
//! Command validation module for security enforcement
|
||||
//!
|
||||
//! This module provides command validation functionality to prevent execution
|
||||
//! of dangerous commands and operations based on configurable blacklists.
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{common::config::Whitelist, config::Blacklist};
|
||||
use rmcp::model::ErrorData;
|
||||
use tracing::error;
|
||||
|
||||
/// Command validator that checks commands against security blacklists
|
||||
/// Prevents execution of dangerous commands and operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Validator {
|
||||
/// Security blacklist configuration containing forbidden commands and operations
|
||||
blacklist: Blacklist,
|
||||
/// Security whitelist configuration containing secure commands
|
||||
whitelist: Whitelist,
|
||||
}
|
||||
|
||||
impl Validator {
|
||||
/// Create a new validator with the specified blacklist configuration
|
||||
pub fn new(blacklist: Blacklist, whitelist: Whitelist) -> Self {
|
||||
Validator {
|
||||
blacklist,
|
||||
whitelist,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a command against the security blacklist and whitelist
|
||||
/// Returns Ok(()) if command is safe, Err(ErrorData) if command is blocked
|
||||
///
|
||||
/// Security Logic:
|
||||
/// 1. All commands are denied by default
|
||||
/// 2. Blacklist has higher priority than whitelist
|
||||
/// 3. If command matches any blacklist pattern (exact or regex), deny
|
||||
/// 4. If command doesn't match blacklist and matches whitelist (exact or regex), allow
|
||||
/// 5. Otherwise, deny
|
||||
pub fn is_unsafe_command(&self, args: &str) -> Result<(), ErrorData> {
|
||||
// Handle empty command - deny by default
|
||||
if args.is_empty() {
|
||||
return Err(ErrorData::invalid_request(
|
||||
"Empty command not allowed".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let full_cmd = args.to_string();
|
||||
|
||||
debug!("Validating command: {}", full_cmd);
|
||||
|
||||
// First check blacklist - if any match, deny immediately
|
||||
// Check blacklist exact commands - match against the full command
|
||||
for blacklisted_cmd in &self.blacklist.commands {
|
||||
if &full_cmd == blacklisted_cmd {
|
||||
error!(
|
||||
"Command blocked by blacklist exact match: {}, full command: {}",
|
||||
blacklisted_cmd, full_cmd
|
||||
);
|
||||
return Err(ErrorData::invalid_request(
|
||||
format!("Command blocked by blacklist: {blacklisted_cmd}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check blacklist regex patterns
|
||||
for pattern in &self.blacklist.regex {
|
||||
match regex::Regex::new(pattern) {
|
||||
Ok(regex) => {
|
||||
if regex.is_match(&full_cmd) {
|
||||
error!(
|
||||
"Command blocked by blacklist regex pattern: {}, full command: {}",
|
||||
pattern, full_cmd
|
||||
);
|
||||
return Err(ErrorData::invalid_request(
|
||||
format!("Command blocked by blacklist pattern: {pattern}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Invalid regex pattern in blacklist: {}, error: {}",
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now check whitelist - if any match, allow
|
||||
// Check whitelist exact commands
|
||||
if self.whitelist.commands.contains(&full_cmd) {
|
||||
debug!("Command allowed by whitelist exact match: {}", full_cmd);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check whitelist regex patterns
|
||||
for pattern in &self.whitelist.regex {
|
||||
match regex::Regex::new(pattern) {
|
||||
Ok(regex) => {
|
||||
if regex.is_match(&full_cmd) {
|
||||
debug!("Command allowed by whitelist regex pattern: {}", pattern);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Invalid regex pattern in whitelist: {}, error: {}",
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default deny - command didn't match whitelist
|
||||
error!("Command denied - not in whitelist: {full_cmd}");
|
||||
Err(ErrorData::invalid_request(
|
||||
format!("Command not allowed: {full_cmd}"),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_validator() -> Validator {
|
||||
let blacklist = Blacklist {
|
||||
commands: vec![
|
||||
"rm".to_string(),
|
||||
"dd".to_string(),
|
||||
"shutdown".to_string(),
|
||||
"kill".to_string(),
|
||||
],
|
||||
regex: vec![
|
||||
".*[|&;><].*".to_string(), // Block commands with dangerous operators
|
||||
"^sudo .*".to_string(), // Block sudo commands
|
||||
".*/etc/.*".to_string(), // Block access to /etc
|
||||
".*passwd.*".to_string(), // Block password-related commands
|
||||
],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec![
|
||||
"echo hello".to_string(),
|
||||
"ls -la".to_string(),
|
||||
"pwd".to_string(),
|
||||
],
|
||||
regex: vec![
|
||||
"^echo [a-zA-Z0-9 ]+$".to_string(), // Only simple echo commands
|
||||
"^ls [a-zA-Z0-9 /-]*$".to_string(), // Only simple ls commands
|
||||
"^(pwd|whoami|date|uptime)$".to_string(), // Basic system info commands
|
||||
],
|
||||
};
|
||||
|
||||
Validator::new(blacklist, whitelist)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validator_creation() {
|
||||
let validator = create_test_validator();
|
||||
assert_eq!(validator.blacklist.commands.len(), 4);
|
||||
assert_eq!(validator.blacklist.regex.len(), 4);
|
||||
assert_eq!(validator.whitelist.commands.len(), 3);
|
||||
assert_eq!(validator.whitelist.regex.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_command() {
|
||||
let validator = create_test_validator();
|
||||
let result = validator.is_unsafe_command("pwd");
|
||||
assert!(result.is_ok()); // pwd is whitelisted
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelisted_command() {
|
||||
let validator = create_test_validator();
|
||||
let result = validator.is_unsafe_command("echo hello");
|
||||
assert!(result.is_ok()); // "echo hello" is exactly whitelisted
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelisted_regex_command() {
|
||||
let validator = create_test_validator();
|
||||
let result = validator.is_unsafe_command("echo test123");
|
||||
assert!(result.is_ok()); // Matches whitelist regex "^echo [a-zA-Z0-9 ]+$"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklisted_command() {
|
||||
let validator = create_test_validator();
|
||||
// Test a command that contains a blacklisted command but isn't exact match
|
||||
let result = validator.is_unsafe_command("rm -rf /");
|
||||
// Should be denied because "rm -rf /" is not in whitelist (default deny)
|
||||
// not because "rm" is blacklisted (since we need exact command match)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklisted_exact_command() {
|
||||
let validator = create_test_validator();
|
||||
// Test exact match against blacklist
|
||||
let result = validator.is_unsafe_command("rm");
|
||||
assert!(result.is_err()); // "rm" exactly matches blacklist
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklisted_regex_operation() {
|
||||
let validator = create_test_validator();
|
||||
// Test pipe operation which should be blocked by regex
|
||||
let result = validator.is_unsafe_command("echo test | cat");
|
||||
assert!(result.is_err()); // Contains "|" which matches ".*[|&;><].*" regex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_blacklisted_commands() {
|
||||
let validator = create_test_validator();
|
||||
|
||||
// Test commands that are exactly in the blacklist
|
||||
let dangerous_commands = vec!["rm", "dd", "shutdown", "kill"];
|
||||
for cmd in dangerous_commands {
|
||||
let result = validator.is_unsafe_command(cmd);
|
||||
assert!(result.is_err(), "Command '{cmd}' should be blocked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_blacklisted_regex_operations() {
|
||||
let validator = create_test_validator();
|
||||
|
||||
let dangerous_ops = vec!["|", "&", ";", ">"];
|
||||
for op in dangerous_ops {
|
||||
let cmd = format!("echo test {op}");
|
||||
let result = validator.is_unsafe_command(&cmd);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Operation '{op}' should be blocked by regex"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_command() {
|
||||
let validator = create_test_validator();
|
||||
let result = validator.is_unsafe_command("");
|
||||
assert!(result.is_err()); // Empty commands are now denied by default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_safe_command() {
|
||||
let validator = create_test_validator();
|
||||
let result = validator.is_unsafe_command("find /tmp -name *.txt");
|
||||
assert!(result.is_err()); // Not in whitelist, so denied by default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_regex_in_whitelist() {
|
||||
let blacklist = Blacklist {
|
||||
commands: vec![],
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec![],
|
||||
regex: vec!["[invalid_regex".to_string()], // Invalid regex
|
||||
};
|
||||
|
||||
let validator = Validator::new(blacklist, whitelist);
|
||||
let result = validator.is_unsafe_command("test");
|
||||
// Should be denied because command is not in whitelist and default is deny
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitivity() {
|
||||
let validator = create_test_validator();
|
||||
|
||||
// Test uppercase version of blacklisted command
|
||||
let result = validator.is_unsafe_command("RM");
|
||||
// Should be denied because not in whitelist (default deny)
|
||||
assert!(result.is_err());
|
||||
|
||||
// Test exact case match for blacklisted command
|
||||
let result = validator.is_unsafe_command("rm");
|
||||
assert!(result.is_err()); // Should be blocked by blacklist
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_command_match() {
|
||||
let validator = create_test_validator();
|
||||
|
||||
// Test command that contains blacklisted word but isn't exact match
|
||||
let result = validator.is_unsafe_command("remove"); // contains "rm" but shouldn't match
|
||||
assert!(result.is_err()); // Should be denied because not in whitelist (default deny)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_priority_over_whitelist() {
|
||||
// Test that blacklist has higher priority than whitelist
|
||||
let blacklist = Blacklist {
|
||||
commands: vec!["echo hello".to_string()], // Changed to exact match
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec!["echo hello".to_string()],
|
||||
regex: vec!["^echo .*".to_string()],
|
||||
};
|
||||
|
||||
let validator = Validator::new(blacklist, whitelist);
|
||||
let result = validator.is_unsafe_command("echo hello");
|
||||
assert!(result.is_err()); // Should be blocked because "echo hello" is in blacklist
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_regex_priority() {
|
||||
// Test that blacklist regex has higher priority than whitelist
|
||||
let blacklist = Blacklist {
|
||||
commands: vec![],
|
||||
regex: vec![".*sudo.*".to_string()],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec!["sudo ls".to_string()],
|
||||
regex: vec!["^sudo .*".to_string()],
|
||||
};
|
||||
|
||||
let validator = Validator::new(blacklist, whitelist);
|
||||
let result = validator.is_unsafe_command("sudo ls");
|
||||
assert!(result.is_err()); // Should be blocked by blacklist regex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_deny_behavior() {
|
||||
// Test that commands not in whitelist are denied by default
|
||||
let blacklist = Blacklist {
|
||||
commands: vec![],
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec!["pwd".to_string()],
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let validator = Validator::new(blacklist, whitelist);
|
||||
|
||||
// Command in whitelist should be allowed
|
||||
let result = validator.is_unsafe_command("pwd");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Command not in whitelist should be denied
|
||||
let result = validator.is_unsafe_command("ls");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_vs_partial_blacklist_matching() {
|
||||
// Test the fix: blacklist should match exactly, not partially
|
||||
let blacklist = Blacklist {
|
||||
commands: vec!["rm".to_string()], // Only "rm" exactly
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let whitelist = Whitelist {
|
||||
commands: vec!["rm -rf /tmp/test".to_string()], // Allow this specific command
|
||||
regex: vec![],
|
||||
};
|
||||
|
||||
let validator = Validator::new(blacklist, whitelist);
|
||||
|
||||
// "rm" exactly should be blocked by blacklist
|
||||
let result = validator.is_unsafe_command("rm");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Exact match 'rm' should be blocked by blacklist"
|
||||
);
|
||||
|
||||
// "rm -rf /tmp/test" should be allowed by whitelist (not blocked by blacklist)
|
||||
let result = validator.is_unsafe_command("rm -rf /tmp/test");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"'rm -rf /tmp/test' should be allowed by whitelist"
|
||||
);
|
||||
|
||||
// "rm -rf /" should be denied (not in whitelist, not exact blacklist match)
|
||||
let result = validator.is_unsafe_command("rm -rf /");
|
||||
assert!(result.is_err(), "'rm -rf /' should be denied by default");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
~ Licensed to the Apache Software Foundation (ASF) under one
|
||||
~ or more contributor license agreements. See the NOTICE file
|
||||
~ distributed with this work for additional information
|
||||
~ regarding copyright ownership. The ASF licenses this file
|
||||
~ to you under the Apache License, Version 2.0 (the
|
||||
~ "License"); you may not use this file except in compliance
|
||||
~ with the License. You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing,
|
||||
~ software distributed under the License is distributed on an
|
||||
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
~ KIND, either express or implied. See the License for the
|
||||
~ specific language governing permissions and limitations
|
||||
~ under the License.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP OAuth Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px auto; max-width: 800px; line-height: 1.6; }
|
||||
h1, h2 { color: #333; }
|
||||
code { background: #f4f4f4; padding: 2px 5px; border-radius: 3px; }
|
||||
.endpoint { background: #f9f9f9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
|
||||
.flow { background: #e8f5e9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MCP OAuth Server</h1>
|
||||
<p>This is an MCP server with OAuth 2.0 integration to a third-party authorization server.</p>
|
||||
|
||||
<h2>Available Endpoints:</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>Authorization Endpoint</h3>
|
||||
<p><code>GET /authorize</code></p>
|
||||
<p>Parameters:</p>
|
||||
<ul>
|
||||
<li><code>response_type</code> - Must be "code"</li>
|
||||
<li><code>client_id</code> - Client identifier (e.g., "mcp-client")</li>
|
||||
<li><code>redirect_uri</code> - URI to redirect after authorization</li>
|
||||
<li><code>scope</code> - Optional requested scope</li>
|
||||
<li><code>state</code> - Optional state value for CSRF prevention</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>Token Endpoint</h3>
|
||||
<p><code>POST /token</code></p>
|
||||
<p>Parameters:</p>
|
||||
<ul>
|
||||
<li><code>grant_type</code> - Must be "authorization_code"</li>
|
||||
<li><code>code</code> - The authorization code</li>
|
||||
<li><code>client_id</code> - Client identifier</li>
|
||||
<li><code>client_secret</code> - Client secret</li>
|
||||
<li><code>redirect_uri</code> - Redirect URI used in authorization request</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>MCP streamablehttp Endpoints</h3>
|
||||
<p><code>/mcp</code> - Streamablehttp connection endpoint (requires OAuth token)</p>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<h2>OAuth Flow:</h2>
|
||||
<ol>
|
||||
<li>MCP Client initiates OAuth flow with this MCP Server</li>
|
||||
<li>MCP Server redirects to Third-Party OAuth Server</li>
|
||||
<li>User authenticates with Third-Party Server</li>
|
||||
<li>Third-Party Server redirects back to MCP Server with auth code</li>
|
||||
<li>MCP Server exchanges the code for a third-party access token</li>
|
||||
<li>MCP Server generates its own token bound to the third-party session</li>
|
||||
<li>MCP Server completes the OAuth flow with the MCP Client</li>
|
||||
</ol>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
//! MCP Bash Server - A Model Context Protocol server for executing bash commands
|
||||
//!
|
||||
//! This server provides secure bash command execution capabilities through the MCP protocol.
|
||||
//! It includes OAuth2 authentication, command validation, and cross-platform support.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Secure command execution with blacklist validation
|
||||
//! - OAuth2 authentication for client authorization
|
||||
//! - Cross-platform shell support (Linux, Windows, macOS)
|
||||
//! - Built-in system information tools
|
||||
//! - Configurable timeout and environment settings
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, Request},
|
||||
middleware::{self, Next},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
// Import modules
|
||||
mod common;
|
||||
use common::bash_server::BashServer;
|
||||
use common::config;
|
||||
use common::oauth::{
|
||||
McpOAuthStore, oauth_approve, oauth_authorization_server, oauth_authorize, oauth_register,
|
||||
oauth_token, validate_token_middleware,
|
||||
};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("html/mcp_oauth_index.html");
|
||||
|
||||
/// Global storage for server bind address, initialized once at startup
|
||||
// Init once from environment variable BIND_ADDRESS
|
||||
pub static BIND_ADDRESS: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Root path handler
|
||||
/// Serves the main OAuth authorization index page
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
/// Wrapper function for oauth_authorization_server to handle BIND_ADDRESS
|
||||
async fn oauth_authorization_server_handler(headers: HeaderMap) -> impl IntoResponse {
|
||||
let bind_address = BIND_ADDRESS
|
||||
.get()
|
||||
.expect("BIND_ADDRESS must be initialized in main()");
|
||||
oauth_authorization_server(bind_address, headers).await
|
||||
}
|
||||
|
||||
/// HTTP request logging middleware
|
||||
/// Logs all incoming requests including method, URI, headers and response status
|
||||
async fn log_request(request: Request<Body>, next: Next) -> Response {
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
let version = request.version();
|
||||
|
||||
// Log headers
|
||||
let headers = request.headers().clone();
|
||||
let mut header_log = String::new();
|
||||
for (key, value) in headers.iter() {
|
||||
let value_str = value.to_str().unwrap_or("<binary>");
|
||||
header_log.push_str(&format!("\n {key}: {value_str}"));
|
||||
}
|
||||
|
||||
// Try to get request body for form submissions
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let request_info = if content_type.contains("application/x-www-form-urlencoded")
|
||||
|| content_type.contains("application/json")
|
||||
{
|
||||
format!("{method} {uri} {version:?}{header_log}\nContent-Type: {content_type}")
|
||||
} else {
|
||||
format!("{method} {uri} {version:?}{header_log}")
|
||||
};
|
||||
|
||||
info!("REQUEST: {}", request_info);
|
||||
|
||||
// Call the actual handler
|
||||
let response = next.run(request).await;
|
||||
|
||||
// Log response status
|
||||
let status = response.status();
|
||||
info!("RESPONSE: {} for {} {}", status, method, uri);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Main application entry point
|
||||
/// Sets up logging, OAuth store, HTTP server, and starts the MCP bash server
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
let logs = tracing_appender::rolling::daily("logs", "mcp.log");
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(logs);
|
||||
let log_setting = tracing_subscriber::fmt::layer().with_writer(non_blocking);
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "debug".to_string().into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(log_setting)
|
||||
.init();
|
||||
|
||||
// Read environment mode from config file, default to "production"
|
||||
let config = config::Config::read_config("config.toml")?;
|
||||
let env_mode = config
|
||||
.settings
|
||||
.env
|
||||
.clone()
|
||||
.unwrap_or_else(|| "production".to_string());
|
||||
let is_dev = env_mode == "development";
|
||||
|
||||
// Create the OAuth store
|
||||
let oauth_store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
let host = config.settings.host.clone();
|
||||
let port = config.settings.port;
|
||||
let bind_address = format!("{host}:{port}");
|
||||
|
||||
let addr = bind_address.parse::<SocketAddr>()?;
|
||||
let _ = BIND_ADDRESS.set(bind_address);
|
||||
|
||||
// Create StreamableHttpServer
|
||||
let service = StreamableHttpService::new(
|
||||
|| Ok(BashServer::new()),
|
||||
LocalSessionManager::default().into(),
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let server_router = Router::new().nest_service("/mcp", service);
|
||||
|
||||
// Add OAuth authentication middleware only if not in development mode
|
||||
let protected_server_router = if is_dev {
|
||||
server_router
|
||||
} else {
|
||||
server_router.layer(middleware::from_fn_with_state(
|
||||
oauth_store.clone(),
|
||||
validate_token_middleware,
|
||||
))
|
||||
};
|
||||
|
||||
// Create CORS layer for the oauth authorization server endpoint
|
||||
let cors_layer = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
// Create a sub-router for the oauth authorization server endpoint with CORS
|
||||
let oauth_server_router = Router::new()
|
||||
.route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
get(oauth_authorization_server_handler).options(oauth_authorization_server_handler),
|
||||
)
|
||||
.route("/token", post(oauth_token).options(oauth_token))
|
||||
.route("/register", post(oauth_register).options(oauth_register))
|
||||
.layer(cors_layer)
|
||||
.with_state(oauth_store.clone());
|
||||
|
||||
// Create HTTP router with request logging middleware
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/authorize", get(oauth_authorize))
|
||||
.route("/approve", post(oauth_approve))
|
||||
.merge(oauth_server_router) // Merge the CORS-enabled oauth server router
|
||||
.merge(protected_server_router)
|
||||
.with_state(oauth_store.clone())
|
||||
.layer(middleware::from_fn(log_request));
|
||||
|
||||
// Start HTTP server
|
||||
info!("MCP OAuth Server started on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async { tokio::signal::ctrl_c().await.unwrap() })
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Method;
|
||||
use axum::http::Request;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_index_handler() {
|
||||
let response = index().await;
|
||||
let html_content = response.0;
|
||||
|
||||
// Verify it returns the expected HTML content
|
||||
assert_eq!(html_content, INDEX_HTML);
|
||||
assert!(html_content.contains("OAuth"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_authorization_server_handler() {
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
// Set up BIND_ADDRESS for testing
|
||||
let _ = BIND_ADDRESS.set("localhost:8080".to_string());
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("host", "localhost:8080".parse().unwrap());
|
||||
|
||||
let response = oauth_authorization_server_handler(headers).await;
|
||||
|
||||
// Test that the handler returns a response
|
||||
// We can't easily test the exact content without mocking, but we can verify it doesn't panic
|
||||
let _response_body = response.into_response();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bind_address_initialization() {
|
||||
// Create a new OnceLock for testing to avoid conflicts
|
||||
let test_bind_address: OnceLock<String> = OnceLock::new();
|
||||
|
||||
// Test that we can set the value once
|
||||
let result = test_bind_address.set("127.0.0.1:9090".to_string());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Test that we can get the value
|
||||
let value = test_bind_address.get();
|
||||
assert!(value.is_some());
|
||||
assert_eq!(value.unwrap(), "127.0.0.1:9090");
|
||||
|
||||
// Test that we can't set it again
|
||||
let result2 = test_bind_address.set("different:port".to_string());
|
||||
assert!(result2.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_html_constant() {
|
||||
// Test that INDEX_HTML is not empty and contains expected content
|
||||
assert!(INDEX_HTML.contains("html") || INDEX_HTML.contains("HTML"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_request_middleware_functionality() {
|
||||
// Test basic properties of log_request function
|
||||
// Since it requires complex setup with actual middleware,
|
||||
// we focus on testing the types and structure
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
// Verify request properties that log_request would process
|
||||
assert_eq!(request.method(), Method::GET);
|
||||
assert_eq!(request.uri().path(), "/test");
|
||||
assert!(request.headers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_imports() {
|
||||
// Test that our modules are properly imported and accessible
|
||||
let _server = BashServer::new();
|
||||
let _store = McpOAuthStore::new();
|
||||
|
||||
// Test config module
|
||||
let config_result = config::Config::read_config("nonexistent.toml");
|
||||
assert!(config_result.is_err()); // Should fail gracefully
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling_types() {
|
||||
// Test that Result type is properly used
|
||||
let test_result: Result<String> = Ok("test".to_string());
|
||||
assert!(test_result.is_ok());
|
||||
|
||||
let test_error: Result<String> = Err(anyhow::anyhow!("test error"));
|
||||
assert!(test_error.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dependencies_availability() {
|
||||
// Test that critical dependencies are available
|
||||
use std::sync::Arc;
|
||||
|
||||
let _arc_store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Test that we can create basic types
|
||||
let _socket_addr: Result<SocketAddr, _> = "127.0.0.1:8080".parse();
|
||||
}
|
||||
|
||||
// ========== OAuth Mock Tests ==========
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_store_functionality() {
|
||||
use common::oauth::{AuthToken, McpOAuthStore};
|
||||
use oauth2::{AccessToken, EmptyExtraTokenFields};
|
||||
|
||||
let store = McpOAuthStore::new();
|
||||
|
||||
// Test client validation
|
||||
let client = store
|
||||
.validate_client("mcp-client", "http://localhost:8080/callback")
|
||||
.await;
|
||||
assert!(client.is_some());
|
||||
|
||||
let invalid_client = store
|
||||
.validate_client("invalid-client", "http://localhost:8080/callback")
|
||||
.await;
|
||||
assert!(invalid_client.is_none());
|
||||
|
||||
// Test auth session creation
|
||||
let session_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile email".to_string()),
|
||||
Some("test-state".to_string()),
|
||||
"test-session-123".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(session_id, "test-session-123");
|
||||
|
||||
// Test token update and MCP token creation
|
||||
let auth_token = AuthToken::new(
|
||||
AccessToken::new("mock-third-party-token".to_string()),
|
||||
oauth2::basic::BasicTokenType::Bearer,
|
||||
EmptyExtraTokenFields {},
|
||||
);
|
||||
|
||||
let update_result = store
|
||||
.update_auth_session_token(&session_id, auth_token)
|
||||
.await;
|
||||
assert!(update_result.is_ok());
|
||||
|
||||
let mcp_token = store.create_mcp_token(&session_id).await;
|
||||
assert!(mcp_token.is_ok());
|
||||
|
||||
let token = mcp_token.unwrap();
|
||||
assert!(token.access_token.starts_with("mcp-token-"));
|
||||
assert_eq!(token.client_id, "mcp-client");
|
||||
|
||||
// Test token validation
|
||||
let validated = store.validate_token(&token.access_token).await;
|
||||
assert!(validated.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_authorization_flow_mock() {
|
||||
use common::oauth::{AuthorizeQuery, McpOAuthStore};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Mock authorization request
|
||||
let auth_query = AuthorizeQuery {
|
||||
response_type: "code".to_string(),
|
||||
client_id: "mcp-client".to_string(),
|
||||
redirect_uri: "http://localhost:8080/callback".to_string(),
|
||||
scope: Some("profile email".to_string()),
|
||||
state: Some("test-state-456".to_string()),
|
||||
};
|
||||
|
||||
// Test that oauth_authorize function can be called
|
||||
// Note: In a real test, we'd use test frameworks like tower::ServiceExt
|
||||
// but here we're testing the basic functionality
|
||||
let store_clone = store.clone();
|
||||
let sessions_before = store_clone.auth_sessions.read().await.len();
|
||||
|
||||
// Verify store is accessible and functional
|
||||
assert_eq!(sessions_before, 0);
|
||||
|
||||
// Test client validation within the flow
|
||||
let client_validation = store
|
||||
.validate_client(&auth_query.client_id, &auth_query.redirect_uri)
|
||||
.await;
|
||||
assert!(client_validation.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_token_exchange_mock() {
|
||||
use common::oauth::AuthToken;
|
||||
use common::oauth::{McpOAuthStore, TokenRequest};
|
||||
use oauth2::{AccessToken, EmptyExtraTokenFields};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Create a session and add auth token (simulating successful OAuth flow)
|
||||
let session_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile".to_string()),
|
||||
Some("test-state".to_string()),
|
||||
"token-exchange-session".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth_token = AuthToken::new(
|
||||
AccessToken::new("mock-external-token".to_string()),
|
||||
oauth2::basic::BasicTokenType::Bearer,
|
||||
EmptyExtraTokenFields {},
|
||||
);
|
||||
|
||||
store
|
||||
.update_auth_session_token(&session_id, auth_token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mock token request (just for structure validation)
|
||||
let _token_request = TokenRequest {
|
||||
grant_type: "authorization_code".to_string(),
|
||||
code: "mock-auth-code".to_string(),
|
||||
client_id: "mcp-client".to_string(),
|
||||
client_secret: "mcp-client-secret".to_string(),
|
||||
redirect_uri: "http://localhost:8080/callback".to_string(),
|
||||
code_verifier: None,
|
||||
refresh_token: "".to_string(),
|
||||
};
|
||||
|
||||
// Test token creation
|
||||
let mcp_token_result = store.create_mcp_token(&session_id).await;
|
||||
assert!(mcp_token_result.is_ok());
|
||||
|
||||
let mcp_token = mcp_token_result.unwrap();
|
||||
assert_eq!(mcp_token.token_type, "bearer");
|
||||
assert_eq!(mcp_token.expires_in, Some(3600));
|
||||
assert!(mcp_token.refresh_token.is_some());
|
||||
|
||||
// Verify token can be validated
|
||||
let validation_result = store.validate_token(&mcp_token.access_token).await;
|
||||
assert!(validation_result.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_middleware_functionality() {
|
||||
use common::oauth::AuthToken;
|
||||
use common::oauth::McpOAuthStore;
|
||||
use oauth2::{AccessToken, EmptyExtraTokenFields};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Create a valid token for middleware testing
|
||||
let session_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile".to_string()),
|
||||
None,
|
||||
"middleware-test-session".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth_token = AuthToken::new(
|
||||
AccessToken::new("middleware-test-token".to_string()),
|
||||
oauth2::basic::BasicTokenType::Bearer,
|
||||
EmptyExtraTokenFields {},
|
||||
);
|
||||
|
||||
store
|
||||
.update_auth_session_token(&session_id, auth_token)
|
||||
.await
|
||||
.unwrap();
|
||||
let mcp_token = store.create_mcp_token(&session_id).await.unwrap();
|
||||
|
||||
// Test token validation (simulating middleware behavior)
|
||||
let valid_token_check = store.validate_token(&mcp_token.access_token).await;
|
||||
assert!(valid_token_check.is_some());
|
||||
|
||||
// Test invalid token
|
||||
let invalid_token_check = store.validate_token("invalid-token-12345").await;
|
||||
assert!(invalid_token_check.is_none());
|
||||
|
||||
// Test empty token
|
||||
let empty_token_check = store.validate_token("").await;
|
||||
assert!(empty_token_check.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_error_handling() {
|
||||
use common::oauth::McpOAuthStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Test creating MCP token without session
|
||||
let no_session_result = store.create_mcp_token("nonexistent-session").await;
|
||||
assert!(no_session_result.is_err());
|
||||
assert_eq!(no_session_result.unwrap_err(), "Session not found");
|
||||
|
||||
// Test creating MCP token without auth token in session
|
||||
let session_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile".to_string()),
|
||||
None,
|
||||
"no-auth-token-session".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let no_auth_token_result = store.create_mcp_token(&session_id).await;
|
||||
assert!(no_auth_token_result.is_err());
|
||||
assert_eq!(
|
||||
no_auth_token_result.unwrap_err(),
|
||||
"No third-party token available for session"
|
||||
);
|
||||
|
||||
// Test updating nonexistent session
|
||||
let auth_token = oauth2::StandardTokenResponse::new(
|
||||
oauth2::AccessToken::new("test-token".to_string()),
|
||||
oauth2::basic::BasicTokenType::Bearer,
|
||||
oauth2::EmptyExtraTokenFields {},
|
||||
);
|
||||
|
||||
let update_nonexistent = store
|
||||
.update_auth_session_token("nonexistent", auth_token)
|
||||
.await;
|
||||
assert!(update_nonexistent.is_err());
|
||||
assert_eq!(update_nonexistent.unwrap_err(), "Session not found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_security_validations() {
|
||||
use common::oauth::McpOAuthStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(McpOAuthStore::new());
|
||||
|
||||
// Test invalid client ID
|
||||
let invalid_client = store
|
||||
.validate_client("malicious-client", "http://localhost:8080/callback")
|
||||
.await;
|
||||
assert!(invalid_client.is_none());
|
||||
|
||||
// Test invalid redirect URI (potential open redirect attack)
|
||||
let malicious_redirect = store
|
||||
.validate_client("mcp-client", "http://evil.com/steal-tokens")
|
||||
.await;
|
||||
assert!(malicious_redirect.is_none());
|
||||
|
||||
// Test valid client with valid redirect URI
|
||||
let valid_client = store
|
||||
.validate_client("mcp-client", "http://localhost:8080/callback")
|
||||
.await;
|
||||
assert!(valid_client.is_some());
|
||||
|
||||
// Test that tokens are properly random and unique
|
||||
let session1_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile".to_string()),
|
||||
None,
|
||||
"security-test-1".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let session2_id = store
|
||||
.create_auth_session(
|
||||
"mcp-client".to_string(),
|
||||
Some("profile".to_string()),
|
||||
None,
|
||||
"security-test-2".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Add auth tokens to both sessions
|
||||
for (i, session_id) in [&session1_id, &session2_id].iter().enumerate() {
|
||||
let auth_token = oauth2::StandardTokenResponse::new(
|
||||
oauth2::AccessToken::new(format!("security-token-{i}")),
|
||||
oauth2::basic::BasicTokenType::Bearer,
|
||||
oauth2::EmptyExtraTokenFields {},
|
||||
);
|
||||
store
|
||||
.update_auth_session_token(session_id, auth_token)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let token1 = store.create_mcp_token(&session1_id).await.unwrap();
|
||||
let token2 = store.create_mcp_token(&session2_id).await.unwrap();
|
||||
|
||||
// Tokens should be different
|
||||
assert_ne!(token1.access_token, token2.access_token);
|
||||
assert_ne!(token1.refresh_token, token2.refresh_token);
|
||||
|
||||
// Both should be valid
|
||||
assert!(store.validate_token(&token1.access_token).await.is_some());
|
||||
assert!(store.validate_token(&token2.access_token).await.is_some());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user