Skip to content

Commit 41ded7e

Browse files
authored
Merge branch 'main' into waldekmastykarz-investigate-issue-1800
2 parents dfb43af + e13fbd3 commit 41ded7e

66 files changed

Lines changed: 2560 additions & 139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎DevProxy.Abstractions/Proxy/IProxyConfiguration.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public enum OutputFormat
2929
public interface IProxyConfiguration
3030
{
3131
int ApiPort { get; set; }
32+
string ApiIpAddress { get; set; }
3233
bool AsSystemProxy { get; set; }
3334
string ConfigFile { get; }
3435
#pragma warning disable CA2227

‎DevProxy.Integration.Tests/TestProxyConfiguration.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace DevProxy.Integration.Tests;
1515
/// </summary>
1616
internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration
1717
{
18+
public string ApiIpAddress { get; set; } = "127.0.0.1";
1819
public int ApiPort { get; set; }
1920
public bool AsSystemProxy { get; set; }
2021
public string ConfigFile { get; set; } = "devproxyrc.json";

‎DevProxy.Tests/ConsoleHotkeyHandlerTests.cs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ namespace DevProxy.Tests;
1515
public sealed class ConsoleHotkeyHandlerTests
1616
{
1717
private static (ConsoleHotkeyHandler handler, FakeProxyStateController controller, RecordingConsole console)
18-
CreateHandler(OutputFormat output = OutputFormat.Text, string ipAddress = "127.0.0.1")
18+
CreateHandler(OutputFormat output = OutputFormat.Text, string apiIpAddress = "127.0.0.1")
1919
{
2020
var controller = new FakeProxyStateController();
2121
var console = new RecordingConsole();
22-
var configuration = new FakeProxyConfiguration { Output = output, IPAddress = ipAddress };
22+
var configuration = new FakeProxyConfiguration { Output = output, ApiIpAddress = apiIpAddress };
2323
var handler = new ConsoleHotkeyHandler(controller, configuration, console);
2424
return (handler, controller, console);
2525
}
@@ -119,10 +119,14 @@ public void PrintApiInstructions_WritesAllApiCommands()
119119
handler.PrintApiInstructions();
120120

121121
var joined = string.Join('\n', console.Lines);
122+
Assert.Contains("Authorization: Bearer <token>", joined, StringComparison.Ordinal);
122123
Assert.Contains("/proxy/mockRequest", joined, StringComparison.Ordinal);
123-
Assert.Contains("\\\"recording\\\": true", joined, StringComparison.Ordinal);
124-
Assert.Contains("\\\"recording\\\": false", joined, StringComparison.Ordinal);
124+
Assert.Contains("\\\"recording\\\":true", joined, StringComparison.Ordinal);
125+
Assert.Contains("\\\"recording\\\":false", joined, StringComparison.Ordinal);
125126
Assert.Contains("/proxy/stopProxy", joined, StringComparison.Ordinal);
127+
var result = Assert.Single(console.Lines, line => line.Contains("\"type\":\"result\"", StringComparison.Ordinal));
128+
Assert.DoesNotContain('\n', result);
129+
Assert.Contains("\"category\":\"ProxyEngine\"", result, StringComparison.Ordinal);
126130
}
127131

128132
[Fact]
@@ -142,7 +146,7 @@ public void PrintApiInstructions_NormalizesIpv6WildcardAddress()
142146

143147
handler.PrintApiInstructions();
144148

145-
Assert.Contains(console.Lines, line => line.Contains("http://127.0.0.1:8897/proxy", StringComparison.Ordinal));
149+
Assert.Contains(console.Lines, line => line.Contains("http://[::1]:8897/proxy", StringComparison.Ordinal));
146150
}
147151

148152
[Fact]

‎DevProxy.Tests/Fakes.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ internal sealed class RecordingConsole : ISystemConsole
6565
}
6666

6767
/// <summary>
68-
/// Minimal <see cref="IProxyConfiguration"/>; only Output/IPAddress/ApiPort/Record
68+
/// Minimal <see cref="IProxyConfiguration"/>; only Output/ApiIpAddress/ApiPort/Record
6969
/// are read by the interactive console, the rest carry inert defaults.
7070
/// </summary>
7171
internal sealed class FakeProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration
7272
{
73+
public string ApiIpAddress { get; set; } = "127.0.0.1";
7374
public int ApiPort { get; set; } = 8897;
7475
public bool AsSystemProxy { get; set; }
7576
public string ConfigFile { get; set; } = "devproxyrc.json";

‎DevProxy/ApiSecurity.cs‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using DevProxy.State;
2+
using System.Net.Http.Headers;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
6+
namespace DevProxy;
7+
8+
internal static class ApiSecurity
9+
{
10+
private static readonly string Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
11+
private static readonly byte[] TokenBytes = Encoding.UTF8.GetBytes(Token);
12+
private static bool _tokenDisplayed;
13+
14+
public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null;
15+
public static string? DisplayToken => ShouldDisplayToken ? Token : null;
16+
17+
public static void LogTokenOnce(ILogger logger)
18+
{
19+
if (!ShouldDisplayToken || _tokenDisplayed)
20+
{
21+
return;
22+
}
23+
24+
_tokenDisplayed = true;
25+
logger.LogInformation("API token: {ApiToken}", Token);
26+
logger.LogInformation("Send this token in the Authorization: Bearer header.");
27+
}
28+
29+
public static string[] GetAllowedOrigins(IConfiguration configuration)
30+
{
31+
var origins = configuration.GetSection("apiAllowedOrigins").Get<string[]>() ?? [];
32+
foreach (var origin in origins)
33+
{
34+
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
35+
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) ||
36+
uri.UserInfo.Length != 0 || origin.Contains('*', StringComparison.Ordinal) ||
37+
!string.Equals(origin, uri.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
38+
{
39+
throw new InvalidOperationException("apiAllowedOrigins must contain exact HTTP or HTTPS origins without paths, wildcards, or trailing slashes.");
40+
}
41+
}
42+
43+
return origins;
44+
}
45+
46+
public static async Task CheckOriginAsync(HttpContext context, Func<Task> next, string[] allowedOrigins)
47+
{
48+
context.Response.Headers.CacheControl = "no-store";
49+
if (context.Request.Headers.TryGetValue("Origin", out var origin) &&
50+
!allowedOrigins.Contains(origin.ToString(), StringComparer.Ordinal))
51+
{
52+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
53+
return;
54+
}
55+
56+
await next();
57+
}
58+
59+
public static string GetTokenFilePath(int pid) =>
60+
Path.Combine(StateManager.GetConfigFolder(), "credentials", $"api-{pid}.token");
61+
62+
public static string GetApiUrl(Microsoft.AspNetCore.Hosting.Server.IServer server, int fallbackPort) =>
63+
GetApiUrl(server.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>()?.Addresses.FirstOrDefault()
64+
?? $"http://127.0.0.1:{fallbackPort}");
65+
66+
public static string GetApiUrl(string url)
67+
{
68+
var address = new UriBuilder(url);
69+
address.Host = address.Uri.IdnHost switch
70+
{
71+
"0.0.0.0" => "127.0.0.1",
72+
"::" => "::1",
73+
_ => address.Host
74+
};
75+
return address.Uri.GetLeftPart(UriPartial.Authority);
76+
}
77+
78+
public static Task SaveTokenAsync(CancellationToken cancellationToken = default)
79+
{
80+
PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder());
81+
PrivateFiles.EnsureDirectory(Path.GetDirectoryName(GetTokenFilePath(Environment.ProcessId))!, secureExisting: true);
82+
return PrivateFiles.WriteAllTextAsync(GetTokenFilePath(Environment.ProcessId), Token, cancellationToken);
83+
}
84+
85+
public static async Task<HttpClient> CreateClientAsync(ProxyInstanceState state, TimeSpan timeout, CancellationToken cancellationToken)
86+
{
87+
if (!Uri.TryCreate(state.ApiUrl, UriKind.Absolute, out var address) ||
88+
address.Scheme != Uri.UriSchemeHttp ||
89+
!System.Net.IPAddress.TryParse(address.IdnHost, out _))
90+
{
91+
throw new InvalidOperationException("The Dev Proxy API address must be an HTTP IP address.");
92+
}
93+
94+
var token = (await File.ReadAllTextAsync(GetTokenFilePath(state.Pid), cancellationToken)).Trim();
95+
var authorization = new AuthenticationHeaderValue("Bearer", token);
96+
#pragma warning disable CA2000
97+
var handler = new HttpClientHandler
98+
{
99+
UseProxy = false,
100+
AllowAutoRedirect = false,
101+
CheckCertificateRevocationList = true
102+
};
103+
#pragma warning restore CA2000
104+
try
105+
{
106+
var client = new HttpClient(handler, disposeHandler: true)
107+
{
108+
BaseAddress = address,
109+
Timeout = timeout
110+
};
111+
client.DefaultRequestHeaders.Authorization = authorization;
112+
return client;
113+
}
114+
catch
115+
{
116+
handler.Dispose();
117+
throw;
118+
}
119+
}
120+
121+
public static async Task AuthenticateAsync(HttpContext context, Func<Task> next)
122+
{
123+
var authorization = context.Request.Headers.Authorization.ToString();
124+
const string prefix = "Bearer ";
125+
if (authorization.Length != prefix.Length + Token.Length ||
126+
!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
127+
!CryptographicOperations.FixedTimeEquals(
128+
Encoding.UTF8.GetBytes(authorization[prefix.Length..]), TokenBytes))
129+
{
130+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
131+
context.Response.Headers.WWWAuthenticate = "Bearer";
132+
return;
133+
}
134+
135+
context.Response.Headers.CacheControl = "no-store";
136+
await next();
137+
}
138+
}

‎DevProxy/Commands/ApiCommand.cs‎

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using DevProxy.Abstractions.Proxy;
66
using DevProxy.Abstractions.Utils;
7+
using DevProxy.State;
78
using Microsoft.Extensions.Logging;
89
using System.CommandLine;
910
using System.CommandLine.Parsing;
@@ -33,17 +34,94 @@ private void ConfigureCommand()
3334
PrintApiInfo(outputFormat);
3435
});
3536

37+
var apiTokenCommand = new Command("token", """
38+
Print the API token of a running Dev Proxy instance.
39+
40+
Examples:
41+
devproxy api token
42+
devproxy api token --pid 12345
43+
devproxy api token --output json
44+
45+
Selects the only running instance. With multiple instances, specify --pid.
46+
Reads credentials for the current user; Dev Proxy must already be running.
47+
Prints the secret to stdout, including when redirected. Errors go to stderr.
48+
JSON output: { "pid": number, "apiUrl": string, "token": string }.
49+
Exit codes: 0 success, 1 instance/credential unavailable, 2 invalid arguments.
50+
""");
51+
var pidOption = new Option<int?>("--pid")
52+
{
53+
Description = "Retrieve the token of a specific Dev Proxy instance"
54+
};
55+
apiTokenCommand.Add(pidOption);
56+
apiTokenCommand.SetAction(async (parseResult, cancellationToken) =>
57+
{
58+
var outputFormat = parseResult.GetValueOrDefault<OutputFormat?>(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text;
59+
return await PrintTokenAsync(parseResult.GetValue(pidOption), outputFormat, cancellationToken);
60+
});
61+
3662
this.AddCommands(new List<Command>
3763
{
38-
apiShowCommand
64+
apiShowCommand,
65+
apiTokenCommand
3966
}.OrderByName());
4067
}
4168

69+
private static async Task<int> PrintTokenAsync(int? pid, OutputFormat outputFormat, CancellationToken cancellationToken)
70+
{
71+
try
72+
{
73+
ProxyInstanceState? state;
74+
if (pid.HasValue)
75+
{
76+
state = await StateManager.LoadStateByPidAsync(pid.Value, cancellationToken);
77+
}
78+
else
79+
{
80+
var states = await StateManager.LoadAllStatesAsync(cancellationToken);
81+
if (states.Count > 1)
82+
{
83+
await Console.Error.WriteLineAsync("Multiple Dev Proxy instances are running. Select one with devproxy api token --pid <PID>:");
84+
foreach (var instance in states.OrderBy(instance => instance.Pid))
85+
{
86+
await Console.Error.WriteLineAsync($" {instance.Pid}: {instance.ApiUrl}");
87+
}
88+
return 1;
89+
}
90+
91+
state = states.SingleOrDefault();
92+
}
93+
94+
if (state is null)
95+
{
96+
await Console.Error.WriteLineAsync(pid.HasValue
97+
? $"No running Dev Proxy instance with PID {pid.Value}. Run devproxy status to find an instance."
98+
: "Dev Proxy is not running. Start it with devproxy first.");
99+
return 1;
100+
}
101+
102+
var token = await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid), cancellationToken);
103+
if (outputFormat == OutputFormat.Json)
104+
{
105+
Console.WriteLine(JsonSerializer.Serialize(new { pid = state.Pid, apiUrl = state.ApiUrl, token }, ProxyUtils.JsonSerializerOptions));
106+
}
107+
else
108+
{
109+
Console.WriteLine(token);
110+
}
111+
return 0;
112+
}
113+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
114+
{
115+
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.");
116+
return 1;
117+
}
118+
}
119+
42120
private void PrintApiInfo(OutputFormat outputFormat)
43121
{
44-
var ipAddress = _proxyConfiguration.IPAddress;
45122
var apiPort = _proxyConfiguration.ApiPort;
46-
var baseUrl = SystemProxyAddress.ToHttpAuthority(ipAddress, apiPort);
123+
var baseUrl = ApiSecurity.GetApiUrl(new UriBuilder(Uri.UriSchemeHttp, _proxyConfiguration.ApiIpAddress, apiPort).Uri.AbsoluteUri);
124+
var tokenFilePattern = Path.Combine(StateManager.GetConfigFolder(), "credentials", "api-<PID>.token");
47125

48126
var endpoints = new[]
49127
{
@@ -61,7 +139,7 @@ private void PrintApiInfo(OutputFormat outputFormat)
61139
var json = JsonSerializer.Serialize(new
62140
{
63141
baseUrl,
64-
swaggerUrl = $"{baseUrl}/swagger/v1/swagger.json",
142+
authentication = new { scheme = "Bearer", header = "Authorization", tokenFilePattern },
65143
endpoints = endpoints.Select(e => new
66144
{
67145
method = e.Method,
@@ -74,7 +152,9 @@ private void PrintApiInfo(OutputFormat outputFormat)
74152
else
75153
{
76154
_logger.LogInformation("Base URL: {BaseUrl}", baseUrl);
77-
_logger.LogInformation("OpenAPI spec: {SwaggerUrl}", $"{baseUrl}/swagger/v1/swagger.json");
155+
_logger.LogInformation("All endpoints require Authorization: Bearer <token>.");
156+
_logger.LogInformation("Get your token: devproxy api token (use --pid <PID> when multiple instances are running).");
157+
_logger.LogInformation("Use devproxy status to discover running instances and their actual API ports.");
78158
_logger.LogInformation("");
79159
_logger.LogInformation("Endpoints:");
80160
foreach (var endpoint in endpoints)
@@ -90,4 +170,4 @@ sealed class ApiEndpointInfo
90170
public string Method { get; set; } = string.Empty;
91171
public string Path { get; set; } = string.Empty;
92172
public string Description { get; set; } = string.Empty;
93-
}
173+
}

0 commit comments

Comments
 (0)