diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index 4d46bfc15..d30ebed8f 100644 --- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs +++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs @@ -29,6 +29,7 @@ public enum OutputFormat public interface IProxyConfiguration { int ApiPort { get; set; } + string ApiIpAddress { get; set; } bool AsSystemProxy { get; set; } string ConfigFile { get; } #pragma warning disable CA2227 diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index 86126d18d..9733939f7 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -15,6 +15,7 @@ namespace DevProxy.Integration.Tests; /// internal sealed class TestProxyConfiguration : IProxyConfiguration { + public string ApiIpAddress { get; set; } = "127.0.0.1"; public int ApiPort { get; set; } public bool AsSystemProxy { get; set; } public string ConfigFile { get; set; } = "devproxyrc.json"; diff --git a/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs b/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs index 53a15455d..b83af21ec 100644 --- a/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs +++ b/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs @@ -15,11 +15,11 @@ namespace DevProxy.Tests; public sealed class ConsoleHotkeyHandlerTests { private static (ConsoleHotkeyHandler handler, FakeProxyStateController controller, RecordingConsole console) - CreateHandler(OutputFormat output = OutputFormat.Text, string ipAddress = "127.0.0.1") + CreateHandler(OutputFormat output = OutputFormat.Text, string apiIpAddress = "127.0.0.1") { var controller = new FakeProxyStateController(); var console = new RecordingConsole(); - var configuration = new FakeProxyConfiguration { Output = output, IPAddress = ipAddress }; + var configuration = new FakeProxyConfiguration { Output = output, ApiIpAddress = apiIpAddress }; var handler = new ConsoleHotkeyHandler(controller, configuration, console); return (handler, controller, console); } @@ -119,10 +119,14 @@ public void PrintApiInstructions_WritesAllApiCommands() handler.PrintApiInstructions(); var joined = string.Join('\n', console.Lines); + Assert.Contains("Authorization: Bearer ", joined, StringComparison.Ordinal); Assert.Contains("/proxy/mockRequest", joined, StringComparison.Ordinal); - Assert.Contains("\\\"recording\\\": true", joined, StringComparison.Ordinal); - Assert.Contains("\\\"recording\\\": false", joined, StringComparison.Ordinal); + Assert.Contains("\\\"recording\\\":true", joined, StringComparison.Ordinal); + Assert.Contains("\\\"recording\\\":false", joined, StringComparison.Ordinal); Assert.Contains("/proxy/stopProxy", joined, StringComparison.Ordinal); + var result = Assert.Single(console.Lines, line => line.Contains("\"type\":\"result\"", StringComparison.Ordinal)); + Assert.DoesNotContain('\n', result); + Assert.Contains("\"category\":\"ProxyEngine\"", result, StringComparison.Ordinal); } [Fact] @@ -142,7 +146,7 @@ public void PrintApiInstructions_NormalizesIpv6WildcardAddress() handler.PrintApiInstructions(); - Assert.Contains(console.Lines, line => line.Contains("http://127.0.0.1:8897/proxy", StringComparison.Ordinal)); + Assert.Contains(console.Lines, line => line.Contains("http://[::1]:8897/proxy", StringComparison.Ordinal)); } [Fact] diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 6913784c9..68b853827 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -65,11 +65,12 @@ internal sealed class RecordingConsole : ISystemConsole } /// -/// Minimal ; only Output/IPAddress/ApiPort/Record +/// Minimal ; only Output/ApiIpAddress/ApiPort/Record /// are read by the interactive console, the rest carry inert defaults. /// internal sealed class FakeProxyConfiguration : IProxyConfiguration { + public string ApiIpAddress { get; set; } = "127.0.0.1"; public int ApiPort { get; set; } = 8897; public bool AsSystemProxy { get; set; } public string ConfigFile { get; set; } = "devproxyrc.json"; diff --git a/DevProxy/ApiSecurity.cs b/DevProxy/ApiSecurity.cs new file mode 100644 index 000000000..d20e3a7fb --- /dev/null +++ b/DevProxy/ApiSecurity.cs @@ -0,0 +1,138 @@ +using DevProxy.State; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; + +namespace DevProxy; + +internal static class ApiSecurity +{ + private static readonly string Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + private static readonly byte[] TokenBytes = Encoding.UTF8.GetBytes(Token); + private static bool _tokenDisplayed; + + public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null; + public static string? DisplayToken => ShouldDisplayToken ? Token : null; + + public static void LogTokenOnce(ILogger logger) + { + if (!ShouldDisplayToken || _tokenDisplayed) + { + return; + } + + _tokenDisplayed = true; + logger.LogInformation("API token: {ApiToken}", Token); + logger.LogInformation("Send this token in the Authorization: Bearer header."); + } + + public static string[] GetAllowedOrigins(IConfiguration configuration) + { + var origins = configuration.GetSection("apiAllowedOrigins").Get() ?? []; + foreach (var origin in origins) + { + if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + uri.UserInfo.Length != 0 || origin.Contains('*', StringComparison.Ordinal) || + !string.Equals(origin, uri.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)) + { + throw new InvalidOperationException("apiAllowedOrigins must contain exact HTTP or HTTPS origins without paths, wildcards, or trailing slashes."); + } + } + + return origins; + } + + public static async Task CheckOriginAsync(HttpContext context, Func next, string[] allowedOrigins) + { + context.Response.Headers.CacheControl = "no-store"; + if (context.Request.Headers.TryGetValue("Origin", out var origin) && + !allowedOrigins.Contains(origin.ToString(), StringComparer.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(); + } + + public static string GetTokenFilePath(int pid) => + Path.Combine(StateManager.GetConfigFolder(), "credentials", $"api-{pid}.token"); + + public static string GetApiUrl(Microsoft.AspNetCore.Hosting.Server.IServer server, int fallbackPort) => + GetApiUrl(server.Features.Get()?.Addresses.FirstOrDefault() + ?? $"http://127.0.0.1:{fallbackPort}"); + + public static string GetApiUrl(string url) + { + var address = new UriBuilder(url); + address.Host = address.Uri.IdnHost switch + { + "0.0.0.0" => "127.0.0.1", + "::" => "::1", + _ => address.Host + }; + return address.Uri.GetLeftPart(UriPartial.Authority); + } + + public static Task SaveTokenAsync(CancellationToken cancellationToken = default) + { + PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder()); + PrivateFiles.EnsureDirectory(Path.GetDirectoryName(GetTokenFilePath(Environment.ProcessId))!, secureExisting: true); + return PrivateFiles.WriteAllTextAsync(GetTokenFilePath(Environment.ProcessId), Token, cancellationToken); + } + + public static async Task CreateClientAsync(ProxyInstanceState state, TimeSpan timeout, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(state.ApiUrl, UriKind.Absolute, out var address) || + address.Scheme != Uri.UriSchemeHttp || + !System.Net.IPAddress.TryParse(address.IdnHost, out _)) + { + throw new InvalidOperationException("The Dev Proxy API address must be an HTTP IP address."); + } + + var token = (await File.ReadAllTextAsync(GetTokenFilePath(state.Pid), cancellationToken)).Trim(); + var authorization = new AuthenticationHeaderValue("Bearer", token); +#pragma warning disable CA2000 + var handler = new HttpClientHandler + { + UseProxy = false, + AllowAutoRedirect = false, + CheckCertificateRevocationList = true + }; +#pragma warning restore CA2000 + try + { + var client = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = address, + Timeout = timeout + }; + client.DefaultRequestHeaders.Authorization = authorization; + return client; + } + catch + { + handler.Dispose(); + throw; + } + } + + public static async Task AuthenticateAsync(HttpContext context, Func next) + { + var authorization = context.Request.Headers.Authorization.ToString(); + const string prefix = "Bearer "; + if (authorization.Length != prefix.Length + Token.Length || + !authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + !CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(authorization[prefix.Length..]), TokenBytes)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer"; + return; + } + + context.Response.Headers.CacheControl = "no-store"; + await next(); + } +} \ No newline at end of file diff --git a/DevProxy/Commands/ApiCommand.cs b/DevProxy/Commands/ApiCommand.cs index 909bf9e4a..78119ddce 100644 --- a/DevProxy/Commands/ApiCommand.cs +++ b/DevProxy/Commands/ApiCommand.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Abstractions.Utils; +using DevProxy.State; using Microsoft.Extensions.Logging; using System.CommandLine; using System.CommandLine.Parsing; @@ -33,17 +34,94 @@ private void ConfigureCommand() PrintApiInfo(outputFormat); }); + var apiTokenCommand = new Command("token", """ + Print the API token of a running Dev Proxy instance. + + Examples: + devproxy api token + devproxy api token --pid 12345 + devproxy api token --output json + + Selects the only running instance. With multiple instances, specify --pid. + Reads credentials for the current user; Dev Proxy must already be running. + Prints the secret to stdout, including when redirected. Errors go to stderr. + JSON output: { "pid": number, "apiUrl": string, "token": string }. + Exit codes: 0 success, 1 instance/credential unavailable, 2 invalid arguments. + """); + var pidOption = new Option("--pid") + { + Description = "Retrieve the token of a specific Dev Proxy instance" + }; + apiTokenCommand.Add(pidOption); + apiTokenCommand.SetAction(async (parseResult, cancellationToken) => + { + var outputFormat = parseResult.GetValueOrDefault(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text; + return await PrintTokenAsync(parseResult.GetValue(pidOption), outputFormat, cancellationToken); + }); + this.AddCommands(new List { - apiShowCommand + apiShowCommand, + apiTokenCommand }.OrderByName()); } + private static async Task PrintTokenAsync(int? pid, OutputFormat outputFormat, CancellationToken cancellationToken) + { + try + { + ProxyInstanceState? state; + if (pid.HasValue) + { + state = await StateManager.LoadStateByPidAsync(pid.Value, cancellationToken); + } + else + { + var states = await StateManager.LoadAllStatesAsync(cancellationToken); + if (states.Count > 1) + { + await Console.Error.WriteLineAsync("Multiple Dev Proxy instances are running. Select one with devproxy api token --pid :"); + foreach (var instance in states.OrderBy(instance => instance.Pid)) + { + await Console.Error.WriteLineAsync($" {instance.Pid}: {instance.ApiUrl}"); + } + return 1; + } + + state = states.SingleOrDefault(); + } + + if (state is null) + { + await Console.Error.WriteLineAsync(pid.HasValue + ? $"No running Dev Proxy instance with PID {pid.Value}. Run devproxy status to find an instance." + : "Dev Proxy is not running. Start it with devproxy first."); + return 1; + } + + var token = await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid), cancellationToken); + if (outputFormat == OutputFormat.Json) + { + Console.WriteLine(JsonSerializer.Serialize(new { pid = state.Pid, apiUrl = state.ApiUrl, token }, ProxyUtils.JsonSerializerOptions)); + } + else + { + Console.WriteLine(token); + } + return 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + await Console.Error.WriteLineAsync("Unable to read the API token. Restart the selected Dev Proxy instance using the current version and the same user account."); + return 1; + } + } + private void PrintApiInfo(OutputFormat outputFormat) { - var ipAddress = _proxyConfiguration.IPAddress; var apiPort = _proxyConfiguration.ApiPort; - var baseUrl = SystemProxyAddress.ToHttpAuthority(ipAddress, apiPort); + var baseUrl = ApiSecurity.GetApiUrl(new UriBuilder(Uri.UriSchemeHttp, _proxyConfiguration.ApiIpAddress, apiPort).Uri.AbsoluteUri); + var tokenFilePattern = Path.Combine(StateManager.GetConfigFolder(), "credentials", "api-.token"); var endpoints = new[] { @@ -61,7 +139,7 @@ private void PrintApiInfo(OutputFormat outputFormat) var json = JsonSerializer.Serialize(new { baseUrl, - swaggerUrl = $"{baseUrl}/swagger/v1/swagger.json", + authentication = new { scheme = "Bearer", header = "Authorization", tokenFilePattern }, endpoints = endpoints.Select(e => new { method = e.Method, @@ -74,7 +152,9 @@ private void PrintApiInfo(OutputFormat outputFormat) else { _logger.LogInformation("Base URL: {BaseUrl}", baseUrl); - _logger.LogInformation("OpenAPI spec: {SwaggerUrl}", $"{baseUrl}/swagger/v1/swagger.json"); + _logger.LogInformation("All endpoints require Authorization: Bearer ."); + _logger.LogInformation("Get your token: devproxy api token (use --pid when multiple instances are running)."); + _logger.LogInformation("Use devproxy status to discover running instances and their actual API ports."); _logger.LogInformation(""); _logger.LogInformation("Endpoints:"); foreach (var endpoint in endpoints) @@ -90,4 +170,4 @@ sealed class ApiEndpointInfo public string Method { get; set; } = string.Empty; public string Path { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 00b9f6ae6..493cd3c26 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -1,6 +1,7 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; using DevProxy.Abstractions.Utils; +using DevProxy.Proxy; using DevProxy.State; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -24,6 +25,7 @@ sealed class DevProxyCommand : RootCommand internal const string PortOptionName = "--port"; internal const string ApiPortOptionName = "--api-port"; + internal const string ApiIpAddressOptionName = "--api-ip-address"; internal const string IpAddressOptionName = "--ip-address"; internal const string LogLevelOptionName = "--log-level"; internal const string RecordOptionName = "--record"; @@ -243,7 +245,7 @@ public DevProxyCommand( IProxyConfiguration proxyConfiguration, IServiceProvider serviceProvider, UpdateNotification updateNotification, - ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes a REST API for runtime management.\n OpenAPI spec: {SystemProxyAddress.ToHttpAuthority(proxyConfiguration.IPAddress, proxyConfiguration.ApiPort)}/swagger\n Use --api-port to configure (default: {proxyConfiguration.ApiPort}).\n Run 'devproxy api show' for more information.") + ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n OpenAPI spec: {ApiSecurity.GetApiUrl(new UriBuilder(Uri.UriSchemeHttp, proxyConfiguration.ApiIpAddress, proxyConfiguration.ApiPort).Uri.AbsoluteUri)}/swagger\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints.") { _serviceProvider = serviceProvider; _plugins = plugins; @@ -310,24 +312,30 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c try { - _app.Lifetime.ApplicationStarted.Register(() => + await ApiSecurity.SaveTokenAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var serverAddresses = _app.Services.GetRequiredService().Features.Get(); + var serverAddress = serverAddresses?.Addresses.FirstOrDefault(); + var address = ApiSecurity.GetApiUrl(serverAddress ?? + new UriBuilder(Uri.UriSchemeHttp, _proxyConfiguration.ApiIpAddress, _proxyConfiguration.ApiPort).Uri.AbsoluteUri); + _logger.LogInformation("Dev Proxy API listening on {Address}...", address); + + if (_proxyConfiguration.Output != OutputFormat.Json) { - var serverAddresses = _app.Services.GetRequiredService().Features.Get(); - var serverAddress = serverAddresses?.Addresses.FirstOrDefault(); - var address = Uri.TryCreate(serverAddress, UriKind.Absolute, out var serverUri) ? - SystemProxyAddress.ToHttpAuthority(serverUri.DnsSafeHost, serverUri.Port) : - SystemProxyAddress.ToHttpAuthority(_proxyConfiguration.IPAddress, _proxyConfiguration.ApiPort); - _logger.LogInformation("Dev Proxy API listening on {Address}...", address); - - // Persist the daemon state so the parent process's readiness check, - // `devproxy stop`, and `devproxy status` can find this instance - // (resolves port 0 to the OS-assigned ports for both proxy and API). - if (IsInternalDaemon) - { - _ = WriteDaemonStateAsync(address); - } - }); - await _app.RunAsync(cancellationToken); + ApiSecurity.LogTokenOnce(_logger); + } + + if (!System.Net.IPAddress.IsLoopback(System.Net.IPAddress.Parse(_proxyConfiguration.ApiIpAddress))) + { + _logger.LogWarning("The Dev Proxy API is bound to {ApiIpAddress} off-loopback. Bearer tokens are sent over HTTP; use only on trusted networks or through a secure tunnel.", _proxyConfiguration.ApiIpAddress); + } + + await WriteInstanceStateAsync(address); + // Hotkeys write directly to the console, so release them after startup logging is complete. + _app.Services.GetRequiredService().CompleteStartupMessages(); + + await _app.WaitForShutdownAsync(cancellationToken); return 0; } @@ -416,6 +424,18 @@ private void ConfigureCommand() Description = "The port for the Dev Proxy API to listen on", HelpName = "api-port" }; + var apiIpAddressOption = new Option(ApiIpAddressOptionName) + { + Description = "The API bind address (default: 127.0.0.1). Use ::1 for IPv6 loopback. Non-loopback binding requires a trusted network", + HelpName = "api-ip-address" + }; + apiIpAddressOption.Validators.Add(input => + { + if (!System.Net.IPAddress.TryParse(input.Tokens[0].Value, out _)) + { + input.AddError("The API address must be an IP address, for example 127.0.0.1 or ::1."); + } + }); var recordOption = new Option(RecordOptionName) { @@ -570,6 +590,7 @@ private void ConfigureCommand() var options = new List