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
92 changes: 92 additions & 0 deletions DevProxy.Tests/ApiSecurityTokenTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using Xunit;

namespace DevProxy.Tests;

public class ApiSecurityTokenTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryResolveToken_NoOverride_GeneratesRandomToken(string? value)
{
Assert.True(ApiSecurity.TryResolveToken(value, out var first, out var error));
Assert.Null(error);
Assert.Equal(64, first.Length);
Assert.True(first.All(Uri.IsHexDigit));

Assert.True(ApiSecurity.TryResolveToken(value, out var second, out _));
Assert.NotEqual(first, second);
}

[Fact]
public void TryResolveToken_ValidOverride_UsesValue()
{
var value = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));

Assert.True(ApiSecurity.TryResolveToken(value, out var token, out var error));
Assert.Null(error);
Assert.Equal(value, token);
}

[Fact]
public void TryResolveToken_OverrideWithSurroundingWhitespace_IsTrimmed()
{
var value = new string('a', 32);

Assert.True(ApiSecurity.TryResolveToken($" {value}\n", out var token, out _));
Assert.Equal(value, token);
}

[Theory]
[InlineData("abcdefghijklmnopqrstuvwxyz-._~+/0123==")]
[InlineData("ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")]
public void TryResolveToken_Base64TokenCharacters_Accepted(string value)
{
Assert.True(ApiSecurity.TryResolveToken(value, out var token, out _));
Assert.Equal(value, token);
}

[Theory]
[InlineData("short")]
[InlineData("0123456789abcdef0123456789abcde")]
public void TryResolveToken_TooShort_Fails(string value)
{
Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
Assert.Contains(ApiSecurity.TokenEnvironmentVariable, error, StringComparison.Ordinal);
Assert.Contains("32", error, StringComparison.Ordinal);
}

[Theory]
[InlineData("0123456789abcdef 0123456789abcdef")]
[InlineData("0123456789abcdef0123456789abcdef\u00e9")]
[InlineData("0123456789abcdef0123456789abcdef;")]
[InlineData("0123456789abcdef=0123456789abcdef")]
public void TryResolveToken_InvalidCharacters_Fails(string value)
{
Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
Assert.Contains(ApiSecurity.TokenEnvironmentVariable, error, StringComparison.Ordinal);
}

[Theory]
[InlineData("================================")]
[InlineData("a===============================")]
public void TryResolveToken_PaddingNotCountedTowardMinimum_Fails(string value)
{
Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
Assert.Contains("padding", error, StringComparison.Ordinal);
}

[Fact]
public void TryResolveToken_MinimumLengthWithPadding_Accepted()
{
var value = new string('a', 32) + "==";

Assert.True(ApiSecurity.TryResolveToken(value, out var token, out _));
Assert.Equal(value, token);
}
}
77 changes: 75 additions & 2 deletions DevProxy/ApiSecurity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,83 @@ 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);
internal const string TokenEnvironmentVariable = "DEV_PROXY_API_TOKEN";
internal const int MinimumTokenLength = 32;

private static readonly Lock TokenLock = new();
private static string? _token;
private static byte[]? _tokenBytes;
private static string Token => EnsureToken().Token;
private static byte[] TokenBytes => EnsureToken().TokenBytes;
private static bool _tokenDisplayed;

/// <summary>
/// Resolves the API token from the value of <c>DEV_PROXY_API_TOKEN</c>.
/// When the value is null or whitespace, a random token is generated.
/// </summary>
internal static bool TryResolveToken(string? environmentValue, out string token, out string? error)
{
error = null;
if (string.IsNullOrWhiteSpace(environmentValue))
{
token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
return true;
}

token = environmentValue.Trim();
if (token.TrimEnd('=').Length < MinimumTokenLength)
{
error = $"{TokenEnvironmentVariable} must be at least {MinimumTokenLength} characters long, not counting trailing = padding.";
return false;
}

// RFC 6750 b64token characters, so the value can be sent as a Bearer credential
if (!token.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '.' or '_' or '~' or '+' or '/' or '=') ||
token.TrimEnd('=').Contains('=', StringComparison.Ordinal))
Comment thread
waldekmastykarz marked this conversation as resolved.
{
error = $"{TokenEnvironmentVariable} may contain only ASCII letters, digits, and the characters - . _ ~ + / (optionally followed by trailing = padding).";
return false;
}

return true;
}

/// <summary>
/// Resolves the API token from the current (effective) value of <c>DEV_PROXY_API_TOKEN</c>.
/// Call after environment overrides are applied. Once resolved, the token is kept for the
/// lifetime of the process, so it survives configuration reloads.
/// </summary>
public static bool TryInitializeToken(out string? error)
{
lock (TokenLock)
{
error = null;
if (_token is not null)
{
return true;
}

if (!TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out var token, out error))
{
return false;
}

_tokenBytes = Encoding.UTF8.GetBytes(token);
_token = token;
return true;
}
}

private static (string Token, byte[] TokenBytes) EnsureToken()
{
if (!TryInitializeToken(out var error))
{
throw new InvalidOperationException(error);
}

return (_token!, _tokenBytes!);
}

public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null;
public static string? DisplayToken => ShouldDisplayToken ? Token : null;

Expand Down
9 changes: 8 additions & 1 deletion DevProxy/Commands/DevProxyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ public DevProxyCommand(
IServiceProvider serviceProvider,
UpdateNotification updateNotification,
DevProxyConfigOptions options,
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.")
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.\n Set the DEV_PROXY_API_TOKEN environment variable (at least 32 characters) to use a fixed API token instead of a generated one.")
{
_serviceProvider = serviceProvider;
_plugins = plugins;
Expand Down Expand Up @@ -284,6 +284,13 @@ private async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken c
{
throw new InvalidOperationException("WebApplication instance is not set. Please provide it when invoking the command.");
}
// Environment overrides from config and --env are applied by now, so this
// validates and pins the effective token before anything reads it.
if (!ApiSecurity.TryInitializeToken(out var tokenError))
{
_logger.LogError("{TokenError}", tokenError);
return 1;
}
if (!_plugins.Any())
{
_logger.LogError("You haven't configured any plugins. Please add plugins to your configuration file. Dev Proxy will exit.");
Expand Down
2 changes: 2 additions & 0 deletions skills/dev-proxy/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ Plugin-specific override:

Every API request requires `Authorization: Bearer <token>`. Startup and `devproxy status` show the per-process token in text and JSON, including redirected output and daemon logs. Tokens survive configuration reloads but change on process restart.

To use a known token, for example when an orchestrator starts Dev Proxy and calls its API, set the `DEV_PROXY_API_TOKEN` environment variable before starting the proxy, or pass it with `--env` or the config `env` section. The value must be at least 32 characters, not counting trailing `=` padding, and use only ASCII letters, digits, `-`, `.`, `_`, `~`, `+`, `/`, and trailing `=`; surrounding whitespace is trimmed. Invalid values stop startup with an error. The token is still saved for `devproxy status`, `devproxy api token`, and `devproxy stop`.

When the `CI` environment variable is set, automatic token output is suppressed. Explicit retrieval remains available:

```bash
Expand Down
Loading