From 27c6fac07dfbe96d20fa28cb46222c32d602984e Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 13:00:08 +0100 Subject: [PATCH 1/4] Allow overriding the API token with DEV_PROXY_API_TOKEN Lets orchestrators such as the Aspire integration start Dev Proxy with a known API token instead of reading per-process token files. Values are trimmed, must be at least 32 characters, and must be valid Bearer token characters; invalid values stop startup with a clear error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/ApiSecurityTokenTests.cs | 74 + DevProxy/ApiSecurity.cs | 49 +- DevProxy/Commands/DevProxyCommand.cs | 1610 +++++++++--------- DevProxy/Program.cs | 953 ++++++----- skills/dev-proxy/references/configuration.md | 2 + 5 files changed, 1410 insertions(+), 1278 deletions(-) create mode 100644 DevProxy.Tests/ApiSecurityTokenTests.cs diff --git a/DevProxy.Tests/ApiSecurityTokenTests.cs b/DevProxy.Tests/ApiSecurityTokenTests.cs new file mode 100644 index 00000000..d744fdbc --- /dev/null +++ b/DevProxy.Tests/ApiSecurityTokenTests.cs @@ -0,0 +1,74 @@ +// 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); + } +} diff --git a/DevProxy/ApiSecurity.cs b/DevProxy/ApiSecurity.cs index a7f5ad00..dccc47c2 100644 --- a/DevProxy/ApiSecurity.cs +++ b/DevProxy/ApiSecurity.cs @@ -7,10 +7,55 @@ 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 Lazy LazyToken = new(() => + TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out var token, out var error) + ? token + : throw new InvalidOperationException(error)); + private static readonly Lazy LazyTokenBytes = new(() => Encoding.UTF8.GetBytes(Token)); + private static string Token => LazyToken.Value; + private static byte[] TokenBytes => LazyTokenBytes.Value; 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.Length < MinimumTokenLength) + { + error = $"{TokenEnvironmentVariable} must be at least {MinimumTokenLength} characters long."; + 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; + } + + /// + /// Validates DEV_PROXY_API_TOKEN so startup can fail with a clear error. + /// + public static bool TryValidateTokenEnvironment(out string? error) => + TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out _, out error); + 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..afee651b 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -1,806 +1,806 @@ -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Proxy; -using DevProxy.State; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using System.CommandLine; -using System.CommandLine.Help; -using System.CommandLine.Invocation; -using System.CommandLine.Parsing; -using System.Globalization; - -namespace DevProxy.Commands; - -sealed class DevProxyCommand : RootCommand -{ - private readonly IServiceProvider _serviceProvider; - private readonly IEnumerable _plugins; - private readonly ILogger _logger; - private readonly IProxyConfiguration _proxyConfiguration; - private readonly ISet _urlsToWatch; - private readonly UpdateNotification _updateNotification; - private WebApplication? _app; - - internal const string PortOptionName = "--port"; - internal const string ApiPortOptionName = "--api-port"; - internal const string ApiIpAddressOptionName = "--api-ip-address"; - internal const string IpAddressOptionName = "--ip-address"; - internal const string LogLevelOptionName = "--log-level"; - internal const string RecordOptionName = "--record"; - internal const string WatchPidsOptionName = "--watch-pids"; - internal const string WatchProcessNamesOptionName = "--watch-process-names"; - internal const string WatchProcessTreeOptionName = "--watch-process-tree"; - internal const string ConfigFileOptionName = "--config-file"; - internal static readonly Option ConfigFileOption = new(ConfigFileOptionName, "-c") - { - HelpName = "config-file", - Description = "Path to config file. If not specified, Dev Proxy searches for devproxyrc.jsonc or devproxyrc.json in the current directory, then in a .devproxy/ directory, then under the ~appFolder location. Supports ~appFolder and ~dataFolder tokens, and a leading ~/ for the home directory." - }; - internal const string NoFirstRunOptionName = "--no-first-run"; - internal const string NoWatchOptionName = "--no-watch"; - internal const string AsSystemProxyOptionName = "--as-system-proxy"; - internal const string InstallCertOptionName = "--install-cert"; - internal const string UrlsToWatchOptionName = "--urls-to-watch"; - internal const string TimeoutOptionName = "--timeout"; - internal const string DiscoverOptionName = "--discover"; - internal const string EnvOptionName = "--env"; - internal const string OutputOptionName = "--output"; - internal const string DetachedOptionName = "--detach"; - internal const string InternalDaemonOptionName = "--_internal-daemon"; - internal const string NoColorOptionName = "--no-color"; - - private static readonly string[] globalOptions = ["--version"]; - private static readonly string[] helpOptions = ["--help", "-h", "/h", "-?", "/?"]; - - private static bool _hasGlobalOptionsResolved; - private static bool _isStdioCommandResolved; - private static bool _isJwtCommandResolved; - private static bool _isRootCommandResolved; - private static bool _isConfigValidateCommandResolved; - private static bool _isDetachedModeResolved; - private static bool _isInternalDaemonResolved; - private static bool _stdioLogFilePathResolved; - private static bool _detachedLogFilePathResolved; - - public static bool HasGlobalOptions - { - get - { - if (_hasGlobalOptionsResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Any(arg => globalOptions.Contains(arg)) || - args.Any(arg => helpOptions.Contains(arg)); - _hasGlobalOptionsResolved = true; - return field; - } - } - - public static bool IsStdioCommand - { - get - { - if (_isStdioCommandResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Contains("stdio"); - _isStdioCommandResolved = true; - return field; - } - } - - public static bool IsJwtCommand - { - get - { - if (_isJwtCommandResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Length > 1 && string.Equals(args[1], "jwt", StringComparison.OrdinalIgnoreCase); - _isJwtCommandResolved = true; - return field; - } - } - - public static bool IsConfigValidateCommand - { - get - { - if (_isConfigValidateCommandResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Length > 2 && - string.Equals(args[1], "config", StringComparison.OrdinalIgnoreCase) && - string.Equals(args[2], "validate", StringComparison.OrdinalIgnoreCase) && - !args.Any(arg => helpOptions.Contains(arg)); - _isConfigValidateCommandResolved = true; - return field; - } - } - - public static bool IsDetachedMode - { - get - { - if (_isDetachedModeResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Contains("--detach") || args.Contains("-d"); - _isDetachedModeResolved = true; - return field; - } - } - - /// - /// Determines if the root command (proxy itself) is being invoked. - /// Returns true when no subcommand is specified (only options or no args). - /// A subcommand is detected when the first non-program argument doesn't start with '-'. - /// - public static bool IsRootCommand - { - get - { - if (_isRootCommandResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - // Skip the first argument which is the program name - // If there are no more arguments, it's the root command - // If the first argument starts with '-', it's an option (root command) - // Otherwise, it's a subcommand name - field = args.Length <= 1 || args[1].StartsWith('-'); - _isRootCommandResolved = true; - return field; - } - } - - public static bool IsInternalDaemon - { - get - { - if (_isInternalDaemonResolved) - { - return field; - } - - var args = Environment.GetCommandLineArgs(); - field = args.Contains("--_internal-daemon"); - _isInternalDaemonResolved = true; - return field; - } - } - - public static string StdioLogFilePath - { - get - { - if (_stdioLogFilePathResolved) - { - return field ?? string.Empty; - } - - field = Path.Combine( - Directory.GetCurrentDirectory(), - $"devproxy-stdio-{DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.log"); - _stdioLogFilePathResolved = true; - return field; - } - } - - public static string DetachedLogFilePath - { - get - { - if (_detachedLogFilePathResolved) - { - return field ?? string.Empty; - } - - field = State.StateManager.GenerateLogFilePath(); - _detachedLogFilePathResolved = true; - return field; - } - } - - public static bool NoColor => ShouldDisableColor( - Environment.GetCommandLineArgs(), - Environment.GetEnvironmentVariable("NO_COLOR"), - Environment.GetEnvironmentVariable("TERM")); - - // Per https://no-color.org, NO_COLOR disables color only when present and not empty. - internal static bool ShouldDisableColor(string[] args, string? noColorEnv, string? termEnv) => - args.Contains(NoColorOptionName) || - !string.IsNullOrEmpty(noColorEnv) || - string.Equals(termEnv, "dumb", StringComparison.OrdinalIgnoreCase); - - public DevProxyCommand( - IEnumerable plugins, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - 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.") - { - _serviceProvider = serviceProvider; - _plugins = plugins; - _urlsToWatch = urlsToWatch; - _proxyConfiguration = proxyConfiguration; - _updateNotification = updateNotification; - _logger = logger; - - ConfigureCommand(options); - } - - public async Task InvokeAsync(string[] args, WebApplication app) - { - _app = app; - // Use special parsing for stdio command to disable response file expansion (@file) - // This allows npm package names like @devproxy/mcp to be passed through literally - var parseResult = IsStdioCommand - ? StdioCommand.ParseStdioArgs(this, args) - : Parse(args); - - if (parseResult.Action is ParseErrorAction parseErrorAction) - { - parseErrorAction.ShowHelp = false; - } - - var exitCode = await parseResult.InvokeAsync(null, app.Lifetime.ApplicationStopping); - - // Return exit code 2 for input validation and parse errors to distinguish - // them from runtime errors (exit code 1), following conventions from - // curl, git, and others - if (exitCode != 0 && parseResult.Errors.Count > 0) - { - return 2; - } - - return exitCode; - } - - private async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) - { - if (_app is null) - { - throw new InvalidOperationException("WebApplication instance is not set. Please provide it when invoking the command."); - } - if (!_plugins.Any()) - { - _logger.LogError("You haven't configured any plugins. Please add plugins to your configuration file. Dev Proxy will exit."); - return 1; - } - if (_urlsToWatch.Count == 0) - { - _logger.LogError("You haven't configured any URLs to watch. Please add URLs to your configuration file or use the --urls-to-watch option. Dev Proxy will exit."); - return 1; - } - - ConfigureFromOptions(parseResult); - var optionsLoadedArgs = new OptionsLoadedArgs(parseResult); - foreach (var plugin in _plugins.Where(p => p.Enabled)) - { - plugin.OptionsLoaded(optionsLoadedArgs); - } - - await CheckForNewVersionAsync(); - - try - { - await ApiSecurity.SaveTokenAsync(cancellationToken); - await _app.StartAsync(cancellationToken); - - var serverAddresses = _app.Services.GetRequiredService().Features.Get(); - var serverAddress = serverAddresses?.Addresses.FirstOrDefault(); - var address = ApiSecurity.GetApiUrl(serverAddress ?? - new UriBuilder(Uri.UriSchemeHttp, _proxyConfiguration.ApiIpAddress, _proxyConfiguration.ApiPort).Uri.AbsoluteUri); - _logger.LogInformation("Dev Proxy API listening on {Address}...", address); - - if (_proxyConfiguration.Output != OutputFormat.Json) - { - ApiSecurity.LogTokenOnce(_logger); - } - - if (!System.Net.IPAddress.IsLoopback(System.Net.IPAddress.Parse(_proxyConfiguration.ApiIpAddress))) - { - _logger.LogWarning("The Dev Proxy API is bound to {ApiIpAddress} off-loopback. Bearer tokens are sent over HTTP; use only on trusted networks or through a secure tunnel.", _proxyConfiguration.ApiIpAddress); - } - - await WriteInstanceStateAsync(address); - // Hotkeys write directly to the console, so release them after startup logging is complete. - _app.Services.GetRequiredService().CompleteStartupMessages(); - - await _app.WaitForShutdownAsync(cancellationToken); - - return 0; - } - catch (TaskCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while running Dev Proxy"); - var inner = ex.InnerException; - - while (inner is not null) - { - _logger.LogError(inner, "============ Inner exception ============"); - inner = inner.InnerException; - } -#if DEBUG - throw; // so debug tools go straight to the source of the exception when attached -#else - return 1; -#endif - } - } - - private void ConfigureCommand(DevProxyConfigOptions configOptions) - { - ConfigFileOption.Validators.Clear(); - ConfigFileOption.Validators.Add(input => - { - var filePath = configOptions.ConfigFile ?? ProxyUtils.ReplacePathTokens(input.Tokens[0].Value); - if (string.IsNullOrEmpty(filePath)) - { - return; - } - - if (!File.Exists(filePath)) - { - input.AddError($"Configuration file '{filePath}' does not exist. Check the file path and try again."); - } - }); - - var ipAddressOption = new Option(IpAddressOptionName) - { - Description = "The IP address for the proxy to bind to", - HelpName = "ip-address" - }; - ipAddressOption.Validators.Add(input => - { - if (!System.Net.IPAddress.TryParse(input.Tokens[0].Value, out _)) - { - input.AddError($"'{input.Tokens[0].Value}' is not a valid IP address. Example: 127.0.0.1"); - } - }); - - var urlsToWatchOption = new Option?>(UrlsToWatchOptionName, "-u") - { - AllowMultipleArgumentsPerToken = true, - Arity = ArgumentArity.ZeroOrMore, - Description = "The list of URLs to watch for requests", - HelpName = "urls-to-watch", - }; - - var logLevelOption = new Option(LogLevelOptionName) - { - Description = $"Level of messages to log. Allowed values: {string.Join(", ", Enum.GetNames())}", - HelpName = "log-level", - Recursive = true - }; - logLevelOption.Validators.Add(input => - { - if (!Enum.TryParse(input.Tokens[0].Value, true, out _)) - { - input.AddError($"'{input.Tokens[0].Value}' is not a valid log level. Allowed values: {string.Join(", ", Enum.GetNames())}"); - } - }); - - var portOption = new Option(PortOptionName, "-p") - { - Description = "The port for the proxy to listen on", - HelpName = "port" - }; - - var apiPortOption = new Option(ApiPortOptionName) - { - Description = "The port for the Dev Proxy API to listen on", - HelpName = "api-port" - }; - var apiIpAddressOption = new Option(ApiIpAddressOptionName) - { - Description = "The API bind address (default: 127.0.0.1). Use ::1 for IPv6 loopback. Non-loopback binding requires a trusted network", - HelpName = "api-ip-address" - }; - apiIpAddressOption.Validators.Add(input => - { - if (!System.Net.IPAddress.TryParse(input.Tokens[0].Value, out _)) - { - input.AddError("The API address must be an IP address, for example 127.0.0.1 or ::1."); - } - }); - - var recordOption = new Option(RecordOptionName) - { - Description = "Use this option to record all request logs" - }; - - var watchPidsOption = new Option>(WatchPidsOptionName) - { - AllowMultipleArgumentsPerToken = true, - Description = "The IDs of processes to watch for requests", - HelpName = "pids" - }; - - var watchProcessNamesOption = new Option>(WatchProcessNamesOptionName) - { - AllowMultipleArgumentsPerToken = true, - Description = "The names of processes to watch for requests", - HelpName = "process-names", - }; - - var watchProcessTreeOption = new Option(WatchProcessTreeOptionName) - { - Description = "Also watch descendants of processes selected by --watch-pids or --watch-process-names" - }; - - var noFirstRunOption = new Option(NoFirstRunOptionName) - { - Description = "Skip the first run experience" - }; - - var noWatchOption = new Option(NoWatchOptionName) - { - Description = "Disable automatic restart on configuration file changes" - }; - - var discoverOption = new Option(DiscoverOptionName) - { - Description = "Run Dev Proxy in discovery mode" - }; - - var asSystemProxyOption = new Option(AsSystemProxyOptionName) - { - Description = "Set Dev Proxy as the system proxy" - }; - - var installCertOption = new Option(InstallCertOptionName) - { - Description = "Install self-signed certificate" - }; - installCertOption.Validators.Add(input => - { - try - { - var asSystemProxy = input.GetValue(asSystemProxyOption) ?? true; - var installCert = input.GetValue(installCertOption) ?? true; - if (asSystemProxy && !installCert) - { - input.AddError($"Requires option '{AsSystemProxyOptionName}' to be 'false'"); - } - } - catch (InvalidOperationException ex) - { - input.AddError(ex.Message); - } - }); - - var timeoutOption = new Option(TimeoutOptionName, "-t") - { - Description = "Time in seconds after which Dev Proxy exits. Resets when Dev Proxy intercepts a request.", - HelpName = "timeout" - }; - timeoutOption.Validators.Add(input => - { - try - { - if (!long.TryParse(input.Tokens[0].Value, out var timeoutInput) || timeoutInput < 1) - { - input.AddError($"'{input.Tokens[0].Value}' is not a valid timeout value. Specify a positive integer (in seconds)."); - } - } - catch (InvalidOperationException ex) - { - input.AddError(ex.Message); - } - }); - - var envOption = new Option(EnvOptionName, "-e") - { - AllowMultipleArgumentsPerToken = true, - Arity = ArgumentArity.ZeroOrMore, - Description = "Environment variables to set for the Dev Proxy process, in name=value format. Also used to replace {name} placeholders in API spec files used by the MinimalPermissions plugins. Changes to HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, or NO_PROXY (including lowercase variants) require a full stop/start; hot reload cannot refresh cached HTTP proxy settings", - HelpName = "env", - }; - envOption.Validators.Add(input => - { - try - { - var envVars = input.GetValue(envOption); - if (envVars is null || envVars.Length == 0) - { - return; - } - - foreach (var envVar in envVars) - { - // Split on first '=' only - var parts = envVar.Split('=', 2); - if (parts.Length != 2 || parts[0].Length == 0) - { - input.AddError($"Invalid environment variable format: '{envVar}'. Expected format is 'name=value'."); - return; - } - } - } - catch (InvalidOperationException ex) - { - input.AddError(ex.Message); - } - }); - - var outputOption = new Option(OutputOptionName) - { - Description = $"Output format. Allowed values: {string.Join(", ", Enum.GetNames())}", - HelpName = "format", - Recursive = true - }; - outputOption.Validators.Add(input => - { - if (input.Tokens.Count == 0) - { - return; - } - if (!Enum.TryParse(input.Tokens[0].Value, true, out _)) - { - input.AddError($"'{input.Tokens[0].Value}' is not a valid output format. Allowed values: {string.Join(", ", Enum.GetNames())}"); - } - }); - - var detachedOption = new Option(DetachedOptionName, "-d") - { - Description = "Run Dev Proxy in the background" - }; - - var internalDaemonOption = new Option(InternalDaemonOptionName) - { - Description = "Internal use only - do not use directly", - Hidden = true - }; - - var noColorOption = new Option(NoColorOptionName) - { - Description = "Disable colored output", - Arity = ArgumentArity.Zero, - Recursive = true - }; - - var options = new List