diff --git a/DevProxy.Tests/ApiSecurityTokenTests.cs b/DevProxy.Tests/ApiSecurityTokenTests.cs new file mode 100644 index 00000000..0912a64c --- /dev/null +++ b/DevProxy.Tests/ApiSecurityTokenTests.cs @@ -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); + } +} diff --git a/DevProxy/ApiSecurity.cs b/DevProxy/ApiSecurity.cs index a7f5ad00..47c0f0a5 100644 --- a/DevProxy/ApiSecurity.cs +++ b/DevProxy/ApiSecurity.cs @@ -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; + /// + /// Resolves the API token from the value of DEV_PROXY_API_TOKEN. + /// When the value is null or whitespace, a random token is generated. + /// + 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)) + { + error = $"{TokenEnvironmentVariable} may contain only ASCII letters, digits, and the characters - . _ ~ + / (optionally followed by trailing = padding)."; + return false; + } + + return true; + } + + /// + /// Resolves the API token from the current (effective) value of DEV_PROXY_API_TOKEN. + /// Call after environment overrides are applied. Once resolved, the token is kept for the + /// lifetime of the process, so it survives configuration reloads. + /// + 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; diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 13fb0418..6577c0bc 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -239,7 +239,7 @@ public DevProxyCommand( IServiceProvider serviceProvider, UpdateNotification updateNotification, DevProxyConfigOptions options, - ILogger 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 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; @@ -284,6 +284,13 @@ private async Task 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."); diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 0f0cecbd..7cbd2222 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -144,6 +144,8 @@ Plugin-specific override: Every API request requires `Authorization: Bearer `. 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