Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces validation for MCP server names to prevent shell injection and path traversal, along with a suite of unit tests. The review feedback recommends removing the dot character from the allowlist to ensure compatibility with major LLM providers' tool naming requirements and to strengthen security against path traversal. Additionally, the test cases should be updated to reflect this change by removing the dot character from the list of accepted names.
| // Allowlist: alphanumeric, dash, underscore, dot. | ||
| // Rejects shell metacharacters (;|&`$), path separators (/\), | ||
| // null bytes, spaces, and other dangerous characters that could | ||
| // cause injection when names are interpolated into secret keys, | ||
| // tool name prefixes, or provider tags. | ||
| if !self | ||
| .name | ||
| .chars() | ||
| .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') | ||
| { | ||
| return Err(ConfigError::InvalidConfig { | ||
| reason: format!( | ||
| "Server name '{}' contains invalid characters \ | ||
| (only alphanumeric, dash, underscore, dot are allowed)", | ||
| self.name | ||
| ), | ||
| }); | ||
| } |
There was a problem hiding this comment.
The allowlist currently includes the dot ('.') character. However, server names are used as prefixes for tool names (e.g., '{server_name}{tool_name}'). Major LLM providers like OpenAI and Anthropic strictly require tool names to match the regex '^[a-zA-Z0-9-]+$'. Allowing dots in the server name will cause tool registration to fail for that server. Furthermore, excluding dots and path separators (/, ) at this boundary prevents path traversal vulnerabilities, as required by repository security rules.
| // Allowlist: alphanumeric, dash, underscore, dot. | |
| // Rejects shell metacharacters (;|&`$), path separators (/\), | |
| // null bytes, spaces, and other dangerous characters that could | |
| // cause injection when names are interpolated into secret keys, | |
| // tool name prefixes, or provider tags. | |
| if !self | |
| .name | |
| .chars() | |
| .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') | |
| { | |
| return Err(ConfigError::InvalidConfig { | |
| reason: format!( | |
| "Server name '{}' contains invalid characters \ | |
| (only alphanumeric, dash, underscore, dot are allowed)", | |
| self.name | |
| ), | |
| }); | |
| } | |
| // Allowlist: alphanumeric, dash, underscore. | |
| // Rejects shell metacharacters (;|& $), path separators (/\), | |
| // null bytes, spaces, dots, and other dangerous characters that could | |
| // cause injection when names are interpolated into secret keys, | |
| // tool name prefixes, or provider tags. | |
| if !self | |
| .name | |
| .chars() | |
| .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') | |
| { | |
| return Err(ConfigError::InvalidConfig { | |
| reason: format!( | |
| "Server name '{}' contains invalid characters \ | |
| (only alphanumeric, dash, underscore are allowed)", | |
| self.name | |
| ), | |
| }); | |
| } |
References
- Keep tool-specific guidance, such as parameter formats, in both the main system prompt for LLM planning and within the tool's own description (tool_info) to ensure it's exposed directly.
- Path traversal characters (/, , .., \0) in extension names must be validated at the public API boundary for all operations (install, activate, remove) to prevent security vulnerabilities.
| #[test] | ||
| fn test_server_name_valid_characters_accepted() { | ||
| // Alphanumeric, dashes, underscores, dots are all valid | ||
| for name in ["notion", "my-server", "my_server", "server.local", "MCP-1"] { |
There was a problem hiding this comment.
|
Worktree review note: I reviewed the full worktree against
This hardening introduces a migration/regression risk for existing users with legacy MCP server names. Validation now rejects anything outside Suggested fix: either normalize legacy names during migration/save, or change load behavior to skip/report invalid entries individually so one historical bad name does not disable every otherwise-valid MCP server. I’d also add a regression test that migrates or loads a mixed config with one legacy invalid name plus one valid server and asserts the valid server still remains usable (or that the legacy one is deterministically renamed). |
1e02155 to
73516ef
Compare
|
Hi @henrypark133 @serrrfirat — all review feedback addressed (dot removed from allowlist, graceful load on invalid names). Rebased onto latest staging. Would appreciate a review when you get a chance. Thanks! |
|
@henrypark133 @serrrfirat @ilblackdragon Ready for review — MCP server name allowlist validation to prevent shell injection (#1882). Tests pass, fmt/clippy clean. |
MCP server names are interpolated into secret keys, tool name prefixes, and provider tags. Without validation, shell metacharacters (;|&`$), path separators (/\), dots, and other special characters in server names could enable injection attacks. This adds an allowlist-based check in McpServerConfig::validate() that only permits [a-zA-Z0-9_-]. Dots are excluded because LLM providers require tool names to match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes. To avoid breaking users with legacy names (e.g. "My Server"), load_mcp_servers_from() and load_mcp_servers_from_db() now skip invalid entries with a tracing::warn instead of failing the entire config load. Supersedes #1941 and incorporates its review feedback (removing dots from the allowlist, graceful degradation on invalid names). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
73516ef to
1dadbe9
Compare
|
@henrypark133 @serrrfirat @ilblackdragon Rebased onto latest staging, CI should be fresh. Would appreciate a review when you get a chance — security fix for MCP server name injection. |
henrypark133
left a comment
There was a problem hiding this comment.
Review: MCP server name validation (Risk: Medium)
Good security hardening — the allowlist approach is solid and the graceful skip for legacy names is well-designed. Two issues worth addressing before merge.
Positives:
- Clean allowlist implementation blocking shell injection, path traversal, null bytes
- Graceful skip-with-warning for legacy names prevents upgrade breakage
- Good test coverage with 6 new tests
Concerning: Uppercase letters silently dropped at runtime [Logic]
File: src/tools/mcp/config.rs:172 + src/extensions/naming.rs:26
The allowlist uses is_ascii_alphanumeric() which permits uppercase (e.g., MCP-1). After factory normalization (factory.rs:40 replaces - with _), inject_mcp_client calls canonicalize_extension_name which only allows is_ascii_lowercase(). Result: MCP-1 passes validation, normalizes to MCP_1, then is silently dropped at manager.rs:1214. The test test_server_name_valid_characters_accepted lists MCP-1 as valid, but it won't work at runtime.
Suggested fix: Restrict the allowlist to lowercase: c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_', and update the test to use mcp-1 instead of MCP-1.
Minor: schema_version silently reset on load [Logic]
File: src/tools/mcp/config.rs:573, src/tools/mcp/config.rs:669
Both load functions construct McpServersFile::default() and copy only servers. The deserialized schema_version is lost (reset to 1). Low impact today but a future-compat footgun.
Suggested fix: let mut valid = McpServersFile { schema_version: config.schema_version, ..Default::default() };
Convention notes:
- The lenient-load / strict-write asymmetry is well-designed but would benefit from a brief comment in both load functions explaining the design choice.
- Missing a test for the DB load path (
load_mcp_servers_from_db) with mixed valid/invalid servers — the file path hastest_load_skips_invalid_server_namebut no parallel DB test.
|
@henrypark133 Both issues addressed in 0ff4e25:
All 49 MCP config tests pass. |
…2400) * fix(mcp): validate server names with strict allowlist (fixes #1882) MCP server names are interpolated into secret keys, tool name prefixes, and provider tags. Without validation, shell metacharacters (;|&`$), path separators (/\), dots, and other special characters in server names could enable injection attacks. This adds an allowlist-based check in McpServerConfig::validate() that only permits [a-zA-Z0-9_-]. Dots are excluded because LLM providers require tool names to match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes. To avoid breaking users with legacy names (e.g. "My Server"), load_mcp_servers_from() and load_mcp_servers_from_db() now skip invalid entries with a tracing::warn instead of failing the entire config load. Supersedes #1941 and incorporates its review feedback (removing dots from the allowlist, graceful degradation on invalid names). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): preserve schema_version when filtering invalid servers McpServersFile::default() sets schema_version to 0 (u32::default), but configs loaded from JSON get schema_version 1 via serde default. The server-filtering logic in load_mcp_servers_from() and load_mcp_servers_from_db() was using McpServersFile::default(), silently downgrading schema_version from 1 to 0 on every load-filter-save cycle (e.g. via bootstrap_nearai_mcp_server). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(mcp): use Vec::retain for server filtering Replace manual for-loop with `retain` as suggested in review — simpler and avoids constructing a new McpServersFile struct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve CI failures — clippy too_many_arguments + restore merge-lost install-param extraction - Add #[allow(clippy::too_many_arguments)] to register_startup_channels - Restore tool_install/tool_activate/tool_auth parameter extraction block in pending_gate_extension_name that was lost during staging merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Zaki <zaki@iqlusion.io> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
…ixes nearai#1882) Server name validation only checked for empty names. Names are interpolated into secret storage keys, tool name prefixes, and provider tags — a name containing shell metacharacters (;|&`$), path separators, or null bytes could cause injection. Switch from blocklist to allowlist: only alphanumeric, dash, underscore, and dot are allowed. Adds 5 regression tests covering shell metacharacters, path separators, null bytes, valid characters, and corrupted config load. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…invalid names - Remove dot from server name allowlist: LLM providers require tool names match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes - Change load_mcp_servers_from() and load_mcp_servers_from_db() to skip invalid entries with a warning instead of failing the entire config, preventing legacy names from disabling all MCP integrations on upgrade - Add test_server_name_dot_rejected regression test - Update load tests to verify skip-invalid behavior with mixed configs Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
0ff4e25 to
b52fdda
Compare
| tokio::fs::write(&path, mixed.to_string()).await.unwrap(); | ||
|
|
||
| let result = load_mcp_servers_from(&path).await.unwrap(); | ||
| assert_eq!(result.servers.len(), 1, "Should skip invalid, keep valid"); |
There was a problem hiding this comment.
Critical — test is internally inconsistent with the loader it exercises.
This test calls load_mcp_servers_from(&path).await.unwrap() and then asserts that result.servers.len() == 1 (invalid skipped, valid kept). But the current loader at lines ~445-449 does:
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
reason: format!("Server '{}': {}", server.name, e),
})?;
}It returns Err on the first invalid server — it does not skip. So .unwrap() will panic, and this test will FAIL on first run. Either:
- Rename/rewrite the test to assert
result.is_err()and that the error namesbad;rm -rf /(matches the siblingtest_load_rejects_corrupted_headersstyle + the PR body's claim oftest_load_rejects_corrupted_server_name), OR - Intentionally change the loader to skip-and-warn (log the bad entry, drop it from the returned list) and keep the test — but then the header-validation loader path diverges in behavior, which is worse.
Option 1 matches the established pattern and this PR's own description. Please fix before merge — this is the single regression test for the core security fix, and as written it cannot run green.
There was a problem hiding this comment.
Fixed. The test already exercises the retain() (skip-and-warn) loader that was adopted after rebase onto staging — the for loop + ? pattern ilblackdragon referenced was from the pre-rebase version. The test runs green and confirms the expected behavior: invalid servers are skipped, valid ones kept.
| .name | ||
| .chars() | ||
| .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') | ||
| .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') |
There was a problem hiding this comment.
High — allowlist is still more permissive than canonicalize_extension_name, keeping the silent-drop failure mode the comment claims to fix.
The updated comment on this line says "Uppercase names pass validation but are silently dropped at runtime" and tightens to lowercase. Good, but the same class of silent-drop remains:
canonicalize_extension_name() in src/extensions/naming.rs replaces - with _ and then rejects:
- leading or trailing
_(line 18) - consecutive
_(line 33)
So the following names all pass this MCP validate(), yet fail (or silently-drop-at-runtime) when the extension manager canonicalizes them:
-server,server-,_server,server_(leading/trailing underscore after replace)my--server,my__server,my-_server(consecutive underscores after replace)- a single
-or_as the entire name
If "uppercase silently dropped" is reason to reject upfront, these are the same bug and should be rejected here as well. Suggested tightening:
// Reuse the canonicalizer as the source of truth — single validation point.
if canonicalize_extension_name(&self.name).is_err() {
return Err(ConfigError::InvalidConfig {
reason: format!("Server name '{}' is not a valid extension identifier", self.name),
});
}…or inline the equivalent rules (first+last char alphanumeric, no consecutive -_). Either way, please add regression tests for -server, server-, my__server, and a bare -. Fixing the pattern, not the instance, per .claude/rules/review-discipline.md.
There was a problem hiding this comment.
Fixed in d6f7863. Replaced the manual char allowlist entirely with ExtensionName::new() as the single source of truth:
ExtensionName::new(&self.name).map_err(|e| ConfigError::InvalidConfig {
reason: format!("Invalid server name '{}': {e}", self.name),
})?;This catches all the edge cases you flagged (-server, server-, my__server, my--server, bare -/_) plus the 64-char limit from the identity layer. Added 6 regression tests covering leading/trailing separators, consecutive separators, bare separators, and length boundaries.
| reason: format!( | ||
| "Server name '{}' contains invalid characters \ | ||
| (only alphanumeric, dash, underscore are allowed)", | ||
| (only lowercase alphanumeric, dash, underscore are allowed)", |
There was a problem hiding this comment.
Medium — no length bound on server name.
validate() rejects empty (line 151) but imposes no upper bound. The name is interpolated into secret-store keys (mcp_{name}_access_token, {token_secret_name}_refresh_token), tool-name prefixes ({server_name}_{tool_name}), and the mcp:{name} provider tag — all of which are persisted and broadcast. A multi-kilobyte name passes every character check in this allowlist and is then stored, logged, and prefixed onto every tool call and secret key.
Suggested: cap at, say, 64 ASCII chars (matches typical DNS-label conventions and leaves headroom for tool-name suffix under the LLM provider's ^[a-zA-Z0-9_-]+$ limit — some providers also cap the full name at 64). Add a test asserting a 65-char name is rejected and a 64-char name is accepted. This is the "length limit" lens from the review checklist and the only remaining dimension not covered by the new tests.
There was a problem hiding this comment.
Fixed in d6f7863. The ExtensionName::new() canonicalizer enforces MAX_NAME_LEN = 64, so this is now handled by the same single-source-of-truth call. Added test_server_name_64_chars_accepted and test_server_name_65_chars_rejected to cover the boundary.
Review summary — security-criticalThanks for taking on #1882. The lens is right — server names absolutely do flow into secret keys, tool-name prefixes, Three findings (inline comments cover detail):
Architectural note: the validation is only invoked on The fundamental direction of this PR is correct and the test matrix for shell metas / path seps / null bytes / dots / uppercase is thorough. Blocking on (1) only; (2) and (3) are strongly encouraged same-PR. |
…a_version Address henrypark133 review feedback: 1. Restrict allowlist to lowercase — uppercase names pass validation but are silently dropped by canonicalize_extension_name() at runtime. 2. Preserve schema_version when filtering invalid servers on load, instead of resetting to default. [skip-regression-check] Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
b52fdda to
8c64726
Compare
… validation Replace the manual character allowlist in validate() with ExtensionName::new() (the canonical identity validator). This fixes three issues from review: 1. Catches edge cases the manual check missed: leading/trailing separators (-server, server_), consecutive separators (my--server, my__server), and bare separators (-, _). 2. Enforces 64-char length limit (MAX_NAME_LEN from ironclaw_common). 3. Single source of truth — validation rules are no longer duplicated between MCP config and the extension naming system. Add 6 regression tests covering leading/trailing separators, consecutive separators, bare separators, 64-char acceptance, and 65-char rejection. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
|
Follow-up on ilblackdragon’s review summary (#1941 (comment)): the three findings called out there are addressed on the current HEAD.
CI is green on the current head. |
…e-validation # Conflicts: # src/tools/mcp/config.rs
|
@ilblackdragon @serrrfirat rebased onto latest staging — was DIRTY because staging landed CI green, all 51 MCP config tests passing. Ready for re-review / approval. |
|
@ilblackdragon brief follow-up after yesterday's rebase — current HEAD now adopts your |
) (nearai#2400) * fix(mcp): validate server names with strict allowlist (fixes nearai#1882) MCP server names are interpolated into secret keys, tool name prefixes, and provider tags. Without validation, shell metacharacters (;|&`$), path separators (/\), dots, and other special characters in server names could enable injection attacks. This adds an allowlist-based check in McpServerConfig::validate() that only permits [a-zA-Z0-9_-]. Dots are excluded because LLM providers require tool names to match ^[a-zA-Z0-9_-]+$ and server names are used as tool name prefixes. To avoid breaking users with legacy names (e.g. "My Server"), load_mcp_servers_from() and load_mcp_servers_from_db() now skip invalid entries with a tracing::warn instead of failing the entire config load. Supersedes nearai#1941 and incorporates its review feedback (removing dots from the allowlist, graceful degradation on invalid names). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): preserve schema_version when filtering invalid servers McpServersFile::default() sets schema_version to 0 (u32::default), but configs loaded from JSON get schema_version 1 via serde default. The server-filtering logic in load_mcp_servers_from() and load_mcp_servers_from_db() was using McpServersFile::default(), silently downgrading schema_version from 1 to 0 on every load-filter-save cycle (e.g. via bootstrap_nearai_mcp_server). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(mcp): use Vec::retain for server filtering Replace manual for-loop with `retain` as suggested in review — simpler and avoids constructing a new McpServersFile struct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve CI failures — clippy too_many_arguments + restore merge-lost install-param extraction - Add #[allow(clippy::too_many_arguments)] to register_startup_channels - Restore tool_install/tool_activate/tool_auth parameter extraction block in pending_gate_extension_name that was lost during staging merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Zaki <zaki@iqlusion.io> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Summary
[a-zA-Z0-9._-]);|&$), path separators (/`), null bytes, and spaces from being accepted as server namesmcp_{name}_access_token), tool name prefixes, and provider tags — malicious characters in names could cause injectionChanges
src/tools/mcp/config.rsvalidate()(alphanumeric + dash + underscore + dot)test_server_name_valid_characters_accepted— confirms legit names passtest_server_name_shell_metacharacters_rejected—;,$(), backticks,|,&,>,<, spacestest_server_name_path_separators_rejected—/,\,../test_server_name_null_byte_rejected—\0test_load_rejects_corrupted_server_name— validates at load time tooTest plan
cargo test --lib tools::mcp::config::tests)cargo fmtcleancargo clippyzero warnings.unwrap()in production code