Skip to content

Commit 8dbf5e2

Browse files
Secures the Dev Proxy API
1 parent ef79a44 commit 8dbf5e2

22 files changed

Lines changed: 663 additions & 143 deletions

‎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/ApiSecurity.cs‎

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

‎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 = $"http://{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+
}

‎DevProxy/Commands/DevProxyCommand.cs‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ sealed class DevProxyCommand : RootCommand
2424

2525
internal const string PortOptionName = "--port";
2626
internal const string ApiPortOptionName = "--api-port";
27+
internal const string ApiIpAddressOptionName = "--api-ip-address";
2728
internal const string IpAddressOptionName = "--ip-address";
2829
internal const string LogLevelOptionName = "--log-level";
2930
internal const string RecordOptionName = "--record";
@@ -243,7 +244,7 @@ public DevProxyCommand(
243244
IProxyConfiguration proxyConfiguration,
244245
IServiceProvider serviceProvider,
245246
UpdateNotification updateNotification,
246-
ILogger<DevProxyCommand> logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes a REST API for runtime management.\n OpenAPI spec: http://{proxyConfiguration.IPAddress ?? "127.0.0.1"}:{proxyConfiguration.ApiPort}/swagger\n Use --api-port to configure (default: {proxyConfiguration.ApiPort}).\n Run 'devproxy api show' for more information.")
247+
ILogger<DevProxyCommand> logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\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.")
247248
{
248249
_serviceProvider = serviceProvider;
249250
_plugins = plugins;
@@ -313,15 +314,8 @@ private async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken c
313314
_app.Lifetime.ApplicationStarted.Register(() =>
314315
{
315316
var serverAddresses = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>();
316-
var address = serverAddresses?.Addresses.FirstOrDefault() ?? $"http://{_proxyConfiguration.IPAddress}:{_proxyConfiguration.ApiPort}";
317+
var address = serverAddresses?.Addresses.FirstOrDefault() ?? $"http://127.0.0.1:{_proxyConfiguration.ApiPort}";
317318
_logger.LogInformation("Dev Proxy API listening on {Address}...", address);
318-
319-
// Update state file with the actual Kestrel API address
320-
// (resolves port 0 to OS-assigned port)
321-
if (IsInternalDaemon)
322-
{
323-
_ = UpdateStateWithApiUrlAsync(address);
324-
}
325319
});
326320
await _app.RunAsync(cancellationToken);
327321

@@ -412,6 +406,18 @@ private void ConfigureCommand()
412406
Description = "The port for the Dev Proxy API to listen on",
413407
HelpName = "api-port"
414408
};
409+
var apiIpAddressOption = new Option<string?>(ApiIpAddressOptionName)
410+
{
411+
Description = "The API bind address (default: 127.0.0.1). Use ::1 for IPv6 loopback. Non-loopback binding requires a trusted network",
412+
HelpName = "api-ip-address"
413+
};
414+
apiIpAddressOption.Validators.Add(input =>
415+
{
416+
if (!System.Net.IPAddress.TryParse(input.Tokens[0].Value, out _))
417+
{
418+
input.AddError("The API address must be an IP address, for example 127.0.0.1 or ::1.");
419+
}
420+
});
415421

416422
var recordOption = new Option<bool?>(RecordOptionName)
417423
{
@@ -566,6 +572,7 @@ private void ConfigureCommand()
566572
var options = new List<Option>
567573
{
568574
apiPortOption,
575+
apiIpAddressOption,
569576
asSystemProxyOption,
570577
ConfigFileOption,
571578
detachedOption,
@@ -655,6 +662,11 @@ private void ConfigureFromOptions(ParseResult parseResult)
655662
_proxyConfiguration.ApiPort = apiPort.Value;
656663
}
657664
var ipAddress = parseResult.GetValueOrDefault<string?>(IpAddressOptionName);
665+
var apiIpAddress = parseResult.GetValueOrDefault<string?>(ApiIpAddressOptionName);
666+
if (apiIpAddress is not null)
667+
{
668+
_proxyConfiguration.ApiIpAddress = apiIpAddress;
669+
}
658670
if (ipAddress is not null)
659671
{
660672
_proxyConfiguration.IPAddress = ipAddress;
@@ -736,13 +748,4 @@ private async Task CheckForNewVersionAsync()
736748
}
737749
}
738750

739-
private static async Task UpdateStateWithApiUrlAsync(string apiUrl)
740-
{
741-
var state = await StateManager.LoadStateByPidAsync(Environment.ProcessId);
742-
if (state is not null)
743-
{
744-
state.ApiUrl = apiUrl;
745-
await StateManager.SaveStateAsync(state);
746-
}
747-
}
748751
}

‎DevProxy/Commands/DevProxyConfigOptions.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public string? ConfigFile
1919
}
2020

2121
public int? ApiPort => _parseResult?.GetValueOrDefault<int?>(DevProxyCommand.ApiPortOptionName);
22+
public string? ApiIpAddress => _parseResult?.GetValueOrDefault<string?>(DevProxyCommand.ApiIpAddressOptionName);
2223
public bool? AsSystemProxy => _parseResult?.GetValueOrDefault<bool?>(DevProxyCommand.AsSystemProxyOptionName);
2324
public int? Port => _parseResult?.GetValueOrDefault<int?>(DevProxyCommand.PortOptionName);
2425
public bool Discover => _parseResult?.GetValueOrDefault<bool?>(DevProxyCommand.DiscoverOptionName) ?? false;
@@ -139,6 +140,7 @@ public DevProxyConfigOptions()
139140
};
140141

141142
var apiPortOption = new Option<int?>(DevProxyCommand.ApiPortOptionName);
143+
var apiIpAddressOption = new Option<string?>(DevProxyCommand.ApiIpAddressOptionName);
142144
var asSystemProxyOption = new Option<bool?>(DevProxyCommand.AsSystemProxyOptionName);
143145
var portOption = new Option<int?>(DevProxyCommand.PortOptionName, "-p");
144146

@@ -155,6 +157,7 @@ public DevProxyConfigOptions()
155157
var options = new List<Option>
156158
{
157159
apiPortOption,
160+
apiIpAddressOption,
158161
asSystemProxyOption,
159162
ipAddressOption,
160163
configFileOption,

0 commit comments

Comments
 (0)