Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
All notable changes to DotNetDevMCP are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/).

## [Unreleased]

### Security
- **`--http` DNS rebinding / cross-origin protection.** Per the MCP Streamable HTTP transport's security guidance, the server
now validates the `Origin` header on every request that carries one: only `http://localhost:<port>`,
`http://127.0.0.1:<port>` and `http://[::1]:<port>` (the server's own port) are accepted, plus any exact value passed via
the new repeatable `--allowed-origin <origin>` option (validated at startup: must be a bare `http`/`https` origin, no
path/query/fragment/userinfo/wildcard). Everything else, including other localhost ports and `https://` origins not
explicitly allow-listed, gets a 403. A request whose `Host` header doesn't name this machine's loopback interface
(`localhost`, `127.0.0.1`, `[::1]`) also gets a 403 (DNS rebinding defense). Requests without an `Origin` header - every
non-browser MCP client, and a browser's simple GET/HEAD - are not rejected by this check; they still only get whatever
the MCP endpoint itself returns for that request (typically 404/405 outside a POST). The server sends no CORS headers,
so `--allowed-origin` does not let a browser page call it directly from that origin - it's for a local dev-server proxy
that forwards the original `Origin`, or a non-browser client that happens to set one. `--http` still has no
authentication or TLS and still shouldn't be exposed beyond localhost.

## [0.3.3] - 2026-09-24

Prompted by an external evaluation; each claim was checked against the code first.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ claude mcp add dotnetdevmcp -- dnx DotNetDevMCP --yes

`dnx` downloads the package from NuGet.org on first run. Prefer a permanent install? `dotnet tool install -g DotNetDevMCP`, then use `dotnetdevmcp` as the command.

Pass `--load-solution <path>` to have Roslyn load your solution at startup, or let the agent call `SharpTool_LoadSolution` when it needs to. `--http --port 3001` serves Streamable HTTP instead of stdio (localhost only, no authentication: see [Security](#security)). `--clean-env` starts `dotnet` and `git` with a minimal environment so tokens and cloud credentials in environment variables aren't passed on. `dotnetdevmcp --help` lists everything.
Pass `--load-solution <path>` to have Roslyn load your solution at startup, or let the agent call `SharpTool_LoadSolution` when it needs to. `--http --port 3001` serves Streamable HTTP instead of stdio (localhost only, no authentication: see [Security](#security)); it rejects requests carrying a foreign `Origin` header or a non-localhost `Host` header. `--allowed-origin <origin>` (repeatable) adds an extra origin to that allow-list, e.g. `--allowed-origin http://localhost:5173` for a local dev-server proxy that forwards its `Origin` - the server sends no CORS headers, so this doesn't let a browser page call it directly. `--clean-env` starts `dotnet` and `git` with a minimal environment so tokens and cloud credentials in environment variables aren't passed on. `dotnetdevmcp --help` lists everything.

Git and Monitoring tools (see the table below) are off by default - a shell an agent already has covers them, and every registered tool costs context tokens in every session. Pass `--enable git,monitoring` (comma-separated and/or repeated, e.g. `--enable git --enable monitoring`) to turn either or both on.

Expand Down
17 changes: 13 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ can't add options such as `-p:CustomBeforeMicrosoftCommonTargets=...` or `--outp
| Default (git and monitoring tools off) | Fewer tools for the agent to misuse | - |
| `--clean-env` | Child processes get a minimal environment: tokens, API keys and cloud credentials in environment variables are not passed on | Not a sandbox: files such as `~/.aws/credentials` and the network are still reachable |
| Edits stay in the solution directory | Roslyn edit tools refuse paths outside it | Doesn't restrict what a build does |
| `--http`'s built-in Origin/Host checks | Rejects requests carrying a foreign `Origin` header and requests whose `Host` doesn't name this machine's loopback interface (DNS rebinding) | Not authentication, and sends no CORS headers: `--allowed-origin` doesn't let a browser page call it directly, only a proxy or non-browser client that sets that Origin; any request without an `Origin` header still reaches the port |

### Untrusted code

Expand All @@ -82,10 +83,18 @@ isolation itself.

### `--http` mode

HTTP mode listens on `localhost` only and has **no authentication, TLS or origin checks**. Anyone who can reach the port can
build, test and edit with your privileges. Don't forward the port, put it behind a proxy, or run it on a shared machine.
A multi-user or hosted deployment would need authentication, a sandbox per session and audit logging; DotNetDevMCP doesn't
provide those today.
HTTP mode listens on `localhost` only. A request that carries an `Origin` header is checked against an allow-list: only
`http://localhost:<port>`, `http://127.0.0.1:<port>` and `http://[::1]:<port>` (the server's own port), plus any exact
value passed via `--allowed-origin` (validated at startup - no wildcards, no path/query/fragment/userinfo), are accepted.
Everything else - including other localhost ports and `https://` origins not explicitly allow-listed - gets a 403. A
request whose `Host` header doesn't name this machine's loopback interface also gets a 403 (DNS rebinding defense).
Requests without an `Origin` header (every non-browser MCP client, and a browser's simple GET/HEAD) are not rejected by
this check; they still only get whatever the MCP endpoint itself returns for that request. The server sends no CORS
headers, so `--allowed-origin` does not let a browser page call it directly from that origin - it's for a local
dev-server proxy that forwards the original `Origin`, or a non-browser client that happens to set one. It still has
**no authentication or TLS**: anyone on the loopback interface who can reach the port can build, test and edit with your
privileges. Don't forward the port, put it behind a proxy, or run it on a shared machine. A multi-user or hosted
deployment would need authentication, a sandbox per session and audit logging; DotNetDevMCP doesn't provide those today.

## Security Updates

Expand Down
76 changes: 76 additions & 0 deletions src/DotNetDevMCP.Server/AllowedOriginValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2025 Ahmed Mustafa
// Validates and normalizes --allowed-origin values before they're handed to LocalOriginGuard, so a
// typo'd or nonsensical value (a path, a wildcard, "null") fails fast at startup instead of silently
// never matching (or, worse, matching more than intended).

namespace DotNetDevMCP.Server;

/// <summary>Validates a single <c>--allowed-origin</c> value.</summary>
public static class AllowedOriginValidation
{
/// <summary>
/// Checks that <paramref name="value"/> is an absolute <c>http</c>/<c>https</c> URI with nothing
/// but scheme, host and optional port - no userinfo, path (other than an implicit trailing "/"),
/// query or fragment - and isn't the literal string "null" or a wildcard.
/// </summary>
/// <param name="value">The raw value from <c>--allowed-origin</c>.</param>
/// <param name="normalized">
/// On success, the value normalized to a bare origin with no trailing slash (e.g.
/// <c>http://localhost:5173/</c> -&gt; <c>http://localhost:5173</c>). On failure, echoes
/// <paramref name="value"/> back unchanged.
/// </param>
/// <returns>A short reason the value is invalid, or null if it's valid.</returns>
public static string? Validate(string value, out string normalized)
{
normalized = value;

if (string.IsNullOrWhiteSpace(value))
{
return "must not be empty.";
}

if (string.Equals(value, "null", StringComparison.OrdinalIgnoreCase))
{
return "'null' is the serialized Origin of an opaque/sandboxed page, not a real origin - it can't be used here.";
}

if (value.Contains('*'))
{
return "wildcards are not allowed; give the exact origin.";
}

if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return "must be an absolute URI, e.g. http://localhost:5173.";
}

if (uri.Scheme is not ("http" or "https"))
{
return $"scheme must be http or https, not '{uri.Scheme}'.";
}

if (!string.IsNullOrEmpty(uri.UserInfo))
{
return "must not include a userinfo (user:pass@) component.";
}

if (uri.AbsolutePath != "/")
{
return "must not include a path.";
}

if (!string.IsNullOrEmpty(uri.Query))
{
return "must not include a query string.";
}

if (!string.IsNullOrEmpty(uri.Fragment))
{
return "must not include a fragment.";
}

// Scheme + host [+ port], no trailing slash - matches how a browser formats the Origin header.
normalized = uri.GetLeftPart(UriPartial.Authority);
return null;
}
}
115 changes: 115 additions & 0 deletions src/DotNetDevMCP.Server/LocalOriginGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2025 Ahmed Mustafa
// DNS-rebinding / cross-origin defense for --http mode, per the MCP Streamable HTTP transport
// security guidance: servers MUST validate the Origin header on incoming connections and SHOULD
// bind only to localhost. We already bind to localhost only; this adds the Origin/Host checks.

namespace DotNetDevMCP.Server;

/// <summary>
/// Rejects any request carrying a foreign <c>Origin</c> header, and any request whose <c>Host</c> header
/// doesn't name this machine's loopback interface, so a malicious page (via DNS rebinding or a plain
/// <c>fetch()</c>) can't reach the MCP server through a victim's browser. Requests without an Origin
/// header - every non-browser MCP client, and a browser's simple GET/HEAD - aren't rejected by this
/// check; they just get whatever the MCP endpoint itself returns for that request.
/// </summary>
public static class LocalOriginGuard
{
private static readonly string[] LoopbackHosts = ["localhost", "127.0.0.1", "[::1]"];

/// <summary>
/// Pure decision logic, kept free of any ASP.NET Core types so it can be unit tested directly.
/// Returns a short reason to reject the request with, or null to allow it.
/// </summary>
/// <param name="origin">The raw <c>Origin</c> header value, or null/empty if absent.</param>
/// <param name="host">The raw <c>Host</c> header value (may include a port), or null/empty if absent.</param>
/// <param name="port">The port this server is listening on.</param>
/// <param name="extraOrigins">Additional allowed origins from <c>--allowed-origin</c>, exact strings.</param>
public static string? Reject(string? origin, string? host, int port, IReadOnlyCollection<string> extraOrigins)
{
if (!string.IsNullOrEmpty(origin))
{
string[] builtIn =
[
$"http://localhost:{port}",
$"http://127.0.0.1:{port}",
$"http://[::1]:{port}",
];

bool allowed = false;
foreach (var candidate in builtIn)
{
if (string.Equals(origin, candidate, StringComparison.OrdinalIgnoreCase)) { allowed = true; break; }
}
if (!allowed)
{
foreach (var candidate in extraOrigins)
{
if (string.Equals(origin, candidate, StringComparison.OrdinalIgnoreCase)) { allowed = true; break; }
}
}

if (!allowed)
{
return $"Origin '{origin}' is not allowed. This server only accepts requests from localhost origins " +
"(plus any configured --allowed-origin).";
}
}

// A missing or empty Host header does reach this code - Kestrel does not reject it for us
// (an HTTP/1.0 request with no Host header, or one with an empty Host value, is passed through).
// We allow it deliberately: every real browser always sends Host, so a request without one is
// necessarily a non-browser client, which isn't the DNS-rebinding threat this check defends against.
if (string.IsNullOrEmpty(host))
{
return null;
}

var hostOnly = StripPort(host);
foreach (var loopback in LoopbackHosts)
{
if (string.Equals(loopback, hostOnly, StringComparison.OrdinalIgnoreCase))
{
return null;
}
}

return $"Host '{host}' is not allowed. This server only accepts requests addressed to localhost, " +
"127.0.0.1 or [::1] (DNS rebinding protection).";
}

/// <summary>Strips the trailing ":port" from a Host header value. IPv6 literals keep their brackets
/// (e.g. "[::1]:3001" -&gt; "[::1]") so they can be compared against <see cref="LoopbackHosts"/> as-is.</summary>
private static string StripPort(string hostHeader)
{
if (hostHeader.StartsWith('['))
{
var end = hostHeader.IndexOf(']');
return end < 0 ? hostHeader : hostHeader[..(end + 1)];
}

var colon = hostHeader.IndexOf(':');
return colon < 0 ? hostHeader : hostHeader[..colon];
}

/// <summary>Registers the guard as middleware. Must run before <c>MapMcp()</c> so a rejected request
/// never reaches the MCP transport.</summary>
public static IApplicationBuilder UseLocalOriginGuard(this IApplicationBuilder app, int port, IReadOnlyCollection<string> extraOrigins)
{
return app.Use(async (context, next) =>
{
var origin = context.Request.Headers.Origin.Count > 0 ? context.Request.Headers.Origin.ToString() : null;
var host = context.Request.Headers.Host.Count > 0 ? context.Request.Headers.Host.ToString() : null;

var reason = Reject(origin, host, port, extraOrigins);
if (reason is not null)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(reason);
return;
}

await next(context);
});
}
}
28 changes: 25 additions & 3 deletions src/DotNetDevMCP.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static async Task<int> Main(string[] args)
var gitCommitEditsOption = new Option<bool>("--git-commit-edits") { Description = "Let edit tools (RenameSymbol, OverwriteMember, AddMember, MoveMember, FindAndReplace, CreateRoslynDocument, OverwriteRoslynDocument, ManageUsings, ManageAttributes) create a git branch and commit after each change, and enable SharpTool_Undo. Off by default: edits are still applied to disk and compile-checked, they just don't touch git or your current branch." };
var disableGitOption = new Option<bool>("--disable-git") { Description = "Deprecated, no-op. Git integration in code-intelligence tools is off by default; use --git-commit-edits to opt in." };
var cleanEnvOption = new Option<bool>("--clean-env") { Description = "Give every dotnet/git child process a minimal, allow-listed environment instead of inheriting this server's full one. Scrubs environment variables only; it is not a sandbox: child processes still run with your user's file-system and network access. Off by default." };
var allowedOriginOption = new Option<string[]>("--allowed-origin") { Description = "Extra Origin allowed for --http (repeatable), e.g. http://localhost:5173", DefaultValueFactory = _ => [] };
var enableOption = new Option<string[]>("--enable")
{
Description = "Enable optional tool groups, off by default: 'git' (repo status/branch/stage/commit/push/pull/log/diff) and 'monitoring' (process performance/GC/health/profiling). Comma-separated and/or repeated, e.g. \"--enable git,monitoring\" or \"--enable git --enable monitoring\".",
Expand All @@ -52,7 +53,7 @@ public static async Task<int> Main(string[] args)

var root = new RootCommand("DotNetDevMCP - MCP server for .NET development: Roslyn code intelligence, build, affected-test selection, git, orchestration.")
{
httpOption, portOption, logDirOption, logLevelOption, loadSolutionOption, buildConfigurationOption, gitCommitEditsOption, disableGitOption, enableOption, cleanEnvOption
httpOption, portOption, logDirOption, logLevelOption, loadSolutionOption, buildConfigurationOption, gitCommitEditsOption, disableGitOption, enableOption, cleanEnvOption, allowedOriginOption
};

var parsed = root.Parse(args);
Expand All @@ -75,6 +76,24 @@ public static async Task<int> Main(string[] args)
bool enableGit = enabledGroups.Contains("git");
bool enableMonitoring = enabledGroups.Contains("monitoring");
bool cleanEnv = parsed.GetValue(cleanEnvOption);
string[] allowedOrigins = parsed.GetValue(allowedOriginOption) ?? [];

// Ignored in stdio mode (--allowed-origin only affects --http), so only validate when it matters:
// a typo'd or nonsensical value should fail fast at startup rather than silently never matching.
if (http && allowedOrigins.Length > 0)
{
var normalized = new string[allowedOrigins.Length];
for (var i = 0; i < allowedOrigins.Length; i++)
{
var error = AllowedOriginValidation.Validate(allowedOrigins[i], out normalized[i]);
if (error is not null)
{
Console.Error.WriteLine($"--allowed-origin '{allowedOrigins[i]}' is invalid: {error}");
return 2;
}
}
allowedOrigins = normalized;
}

Log.Logger = BuildLogger(logLevel, logDir);

Expand All @@ -99,7 +118,7 @@ public static async Task<int> Main(string[] args)
Log.Information("Starting {App} v{Version} ({Transport})", ApplicationName, ApplicationVersion, http ? $"http://localhost:{port}" : "stdio");

IHost host = http
? BuildHttpHost(args, port, gitCommitEdits, buildConfiguration, enableGit, enableMonitoring)
? BuildHttpHost(args, port, gitCommitEdits, buildConfiguration, enableGit, enableMonitoring, allowedOrigins)
: BuildStdioHost(args, gitCommitEdits, buildConfiguration, enableGit, enableMonitoring);

if (!string.IsNullOrEmpty(solutionPath))
Expand Down Expand Up @@ -130,13 +149,16 @@ private static IHost BuildStdioHost(string[] args, bool gitCommitEdits, string?
return builder.Build();
}

private static IHost BuildHttpHost(string[] args, int port, bool gitCommitEdits, string? buildConfiguration, bool enableGit, bool enableMonitoring)
private static IHost BuildHttpHost(string[] args, int port, bool gitCommitEdits, string? buildConfiguration, bool enableGit, bool enableMonitoring, IReadOnlyCollection<string> allowedOrigins)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args });
builder.Host.UseSerilog();
builder.WebHost.UseUrls($"http://localhost:{port}");
AddServices(builder.Services, gitCommitEdits, buildConfiguration, enableGit, enableMonitoring).WithHttpTransport();
var app = builder.Build();
// MCP Streamable HTTP transport security requirement: validate Origin (DNS rebinding defense)
// and localhost-only Host before any request reaches the MCP endpoint.
app.UseLocalOriginGuard(port, allowedOrigins);
app.MapMcp();
return app;
}
Expand Down
Loading
Loading