diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index b773359f1..115f56e7b 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -73,6 +73,7 @@ Error message: {0}", error); } public static string WebserverApiOnlyDescription { get { return LC.L(@"Disable the web interface and only allow API access"); } } public static string WebserverDisableSigninTokensDescription { get { return LC.L(@"Disable the use of signin tokens"); } } public static string WebserverSpaPathsDescription { get { return LC.L(@"The relative paths that should be served as single page applications, separated with semicolons."); } } + public static string WebserverCorsOriginsDescription { get { return LC.L(@"A list of CORS origins to allow, separated with semicolons. Each origin must be a valid URL."); } } public static string WebserverTimezoneDescription { get { return LC.L(@"The timezone to use for the webserver. The timezone must be a valid timezone identifier, such as ""America/New_York"" or ""UTC"". Common three-letter abbreviations like ""CET"" are supported, but ambiguous in some cases."); } } public static string DisabledbencryptionLong { get { return LC.L(@"Use this option to disable database encryption of sensitive fields"); } } public static string DisabledbencryptionShort { get { return LC.L(@"Disable database encryption"); } } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 7b2fb9c0a..94215b83c 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -403,7 +403,8 @@ namespace Duplicati.Server parsedOptions.Servername, parsedOptions.AllowedHostnames, parsedOptions.DisableStaticFiles, - parsedOptions.SPAPaths + parsedOptions.SPAPaths, + parsedOptions.CorsOrigins ); var server = DuplicatiWebserver.CreateWebServer(mappedSettings, connection); @@ -980,6 +981,7 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableSigninTokensDescription, Strings.Program.WebserverDisableSigninTokensDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_SPAPATHS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverSpaPathsDescription, Strings.Program.WebserverSpaPathsDescription, WebServerLoader.DEFAULT_OPTION_SPAPATHS), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_TIMEZONE, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverTimezoneDescription, Strings.Program.WebserverTimezoneDescription, TimeZoneHelper.GetLocalTimeZone(), null, TimeZoneHelper.GetTimeZones().Select(x => x.Id).ToArray()), + new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_CORS_ORIGINS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverCorsOriginsDescription, Strings.Program.WebserverCorsOriginsDescription, WebServerLoader.DEFAULT_OPTION_SPAPATHS), new Duplicati.Library.Interface.CommandLineArgument(PING_PONG_KEEPALIVE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PingpongkeepaliveShort, Strings.Program.PingpongkeepaliveLong), new Duplicati.Library.Interface.CommandLineArgument("log-retention", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Timespan, Strings.Program.LogretentionShort, Strings.Program.LogretentionLong, DEFAULT_LOG_RETENTION), new Duplicati.Library.Interface.CommandLineArgument("server-datafolder", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ServerdatafolderShort, Strings.Program.ServerdatafolderLong(DATAFOLDER_ENV_NAME), System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Library.AutoUpdater.AutoUpdateSettings.AppName)), diff --git a/Duplicati/Server/WebServerLoader.cs b/Duplicati/Server/WebServerLoader.cs index 7f14dd5d0..3b82cf767 100644 --- a/Duplicati/Server/WebServerLoader.cs +++ b/Duplicati/Server/WebServerLoader.cs @@ -99,6 +99,10 @@ public static class WebServerLoader /// public const string OPTION_WEBSERVICE_SPAPATHS = "webservice-spa-paths"; /// + /// The CORS origins to allow + /// + public const string OPTION_WEBSERVICE_CORS_ORIGINS = "webservice-cors-origins"; + /// /// Option for setting the webservice timezone /// public const string OPTION_WEBSERVICE_TIMEZONE = "webservice-timezone"; @@ -154,6 +158,7 @@ public static class WebServerLoader /// The allowed hostnames /// If static files should be disabled /// The paths to serve as SPAs + /// The origins to allow for CORS public record ParsedWebserverSettings( string WebRoot, int Port, @@ -162,7 +167,8 @@ public static class WebServerLoader string Servername, IEnumerable AllowedHostnames, bool DisableStaticFiles, - IEnumerable SPAPaths + IEnumerable SPAPaths, + IEnumerable CorsOrigins ); @@ -251,7 +257,8 @@ public static class WebServerLoader string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, Library.AutoUpdater.UpdaterManager.SelfVersion.Version), (connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries), Duplicati.Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_API_ONLY), - spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries), + options.GetValueOrDefault(OPTION_WEBSERVICE_CORS_ORIGINS)?.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty() ); // Materialize the list of ports, and move the last-used port to the front, so we try the last-known port first diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 2010d70b4..9f5f0b4fc 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -48,6 +48,11 @@ public class DuplicatiWebserver /// The log tag for this class /// private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); + /// + /// The name of the CORS policy + /// + public const string CorsPolicyName = "CustomCorsPolicy"; + /// /// The configuration for the server /// @@ -68,6 +73,11 @@ public class DuplicatiWebserver /// public required int Port { get; init; } + /// + /// The port the server is listening on + /// + public required bool CorsEnabled { get; init; } + /// /// The task that will be set when the server is terminated /// @@ -93,6 +103,7 @@ public class DuplicatiWebserver /// The allowed hostnames /// If static files should be disabled /// The paths to serve as SPAs + /// The origins to allow for CORS public record InitSettings( string WebRoot, int Port, @@ -101,7 +112,8 @@ public class DuplicatiWebserver string Servername, IEnumerable AllowedHostnames, bool DisableStaticFiles, - IEnumerable SPAPaths + IEnumerable SPAPaths, + IEnumerable CorsOrigins ); /// @@ -253,6 +265,22 @@ public class DuplicatiWebserver builder.Services.AddHttpClient(); + var useCors = settings.CorsOrigins != null && settings.CorsOrigins.Any(); + if (useCors) + { + builder.Services.AddCors(options => + { + options.AddPolicy( + name: CorsPolicyName, + policy => + { + policy.WithOrigins(settings.CorsOrigins!.ToArray()) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + } + var app = builder.Build(); HttpClientHelper.Configure(app.Services.GetRequiredService()); @@ -271,6 +299,9 @@ public class DuplicatiWebserver if (!settings.DisableStaticFiles) app.UseDefaultStaticFiles(settings.WebRoot, settings.SPAPaths); + if (useCors) + app.UseCors(CorsPolicyName); + app.UseExceptionHandler(app => { app.Run(async context => @@ -308,7 +339,8 @@ public class DuplicatiWebserver { Configuration = builder.Configuration, App = app, - Port = settings.Port + Port = settings.Port, + CorsEnabled = useCors }; } @@ -345,7 +377,7 @@ public class DuplicatiWebserver public Task Start() { App.MapHealthChecks("/health"); - App.AddEndpoints() + App.AddEndpoints(CorsEnabled) .UseNotifications("/notifications"); return TerminationTask = App.RunAsync(); diff --git a/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs b/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs index 00485cef9..254551ef9 100644 --- a/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs +++ b/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs @@ -26,12 +26,12 @@ namespace Duplicati.WebserverCore.Extensions; public static class WebApplicationExtensions { - public static WebApplication AddEndpoints(this WebApplication application) + public static WebApplication AddEndpoints(this WebApplication application, bool useCors) { - return AddV1(application); + return AddV1(application, useCors); } - private static WebApplication AddV1(WebApplication application) + private static WebApplication AddV1(WebApplication application, bool useCors) { var mapperInterfaceType = typeof(IEndpointV1); var endpoints = @@ -46,6 +46,9 @@ public static class WebApplicationExtensions if (!string.IsNullOrWhiteSpace(PreSharedKeyFilter.PreSharedKey)) group = group.AddEndpointFilter(); + if (useCors) + group.RequireCors(DuplicatiWebserver.CorsPolicyName); + foreach (var endpoint in endpoints) { var methodMap = endpoint.GetMethod(nameof(IEndpointV1.Map), BindingFlags.Static | BindingFlags.Public);