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
1 change: 1 addition & 0 deletions DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions DevProxy.Integration.Tests/TestProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace DevProxy.Integration.Tests;
/// </summary>
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";
Expand Down
14 changes: 9 additions & 5 deletions DevProxy.Tests/ConsoleHotkeyHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -119,10 +119,14 @@ public void PrintApiInstructions_WritesAllApiCommands()
handler.PrintApiInstructions();

var joined = string.Join('\n', console.Lines);
Assert.Contains("Authorization: Bearer <token>", 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]
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion DevProxy.Tests/Fakes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ internal sealed class RecordingConsole : ISystemConsole
}

/// <summary>
/// Minimal <see cref="IProxyConfiguration"/>; only Output/IPAddress/ApiPort/Record
/// Minimal <see cref="IProxyConfiguration"/>; only Output/ApiIpAddress/ApiPort/Record
/// are read by the interactive console, the rest carry inert defaults.
/// </summary>
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";
Expand Down
138 changes: 138 additions & 0 deletions DevProxy/ApiSecurity.cs
Original file line number Diff line number Diff line change
@@ -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<string[]>() ?? [];
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<Task> 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<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>()?.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<HttpClient> 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<Task> 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();
}
}
92 changes: 86 additions & 6 deletions DevProxy/Commands/ApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int?>("--pid")
{
Description = "Retrieve the token of a specific Dev Proxy instance"
};
apiTokenCommand.Add(pidOption);
apiTokenCommand.SetAction(async (parseResult, cancellationToken) =>
{
var outputFormat = parseResult.GetValueOrDefault<OutputFormat?>(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text;
return await PrintTokenAsync(parseResult.GetValue(pidOption), outputFormat, cancellationToken);
});

this.AddCommands(new List<Command>
{
apiShowCommand
apiShowCommand,
apiTokenCommand
}.OrderByName());
}

private static async Task<int> 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 <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-<PID>.token");

var endpoints = new[]
{
Expand All @@ -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,
Expand All @@ -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 <token>.");
_logger.LogInformation("Get your token: devproxy api token (use --pid <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)
Expand All @@ -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;
}
}
Loading
Loading