diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 473158a30..93a4d73e5 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -187,6 +187,20 @@ namespace BTCPayServer.Controllers if (model == null) return NotFound(); + + _CSP.Add(new ConsentSecurityPolicy("script-src", "'unsafe-eval'")); // Needed by Vue + if(!string.IsNullOrEmpty(model.CustomCSSLink) && + Uri.TryCreate(model.CustomCSSLink, UriKind.Absolute, out var uri)) + { + _CSP.Clear(); + } + + if (!string.IsNullOrEmpty(model.CustomLogoLink) && + Uri.TryCreate(model.CustomLogoLink, UriKind.Absolute, out uri)) + { + _CSP.Clear(); + } + return View(nameof(Checkout), model); } diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index cd148bfd7..c10dee424 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -41,12 +41,14 @@ using NBXplorer; using BTCPayServer.HostedServices; using BTCPayServer.Payments; using BTCPayServer.Rating; +using BTCPayServer.Security; namespace BTCPayServer.Controllers { public partial class InvoiceController : Controller { InvoiceRepository _InvoiceRepository; + ContentSecurityPolicies _CSP; BTCPayRateProviderFactory _RateProvider; StoreRepository _StoreRepository; UserManager _UserManager; @@ -64,6 +66,7 @@ namespace BTCPayServer.Controllers StoreRepository storeRepository, EventAggregator eventAggregator, BTCPayWalletProvider walletProvider, + ContentSecurityPolicies csp, BTCPayNetworkProvider networkProvider) { _ServiceProvider = serviceProvider; @@ -75,6 +78,7 @@ namespace BTCPayServer.Controllers _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _WalletProvider = walletProvider; + _CSP = csp; } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 8943b32de..6cc9ab931 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -98,6 +98,26 @@ namespace BTCPayServer return str + "/"; } + public static void SetHeaderOnStarting(this HttpResponse resp, string name, string value) + { + if (resp.HasStarted) + return; + resp.OnStarting(() => + { + SetHeader(resp, name, value); + return Task.CompletedTask; + }); + } + + public static void SetHeader(this HttpResponse resp, string name, string value) + { + var existing = resp.Headers[name].FirstOrDefault(); + if (existing != null && value == null) + resp.Headers.Remove(name); + else + resp.Headers[name] = value; + } + public static string GetAbsoluteRoot(this HttpRequest request) { return string.Concat( diff --git a/BTCPayServer/Filters/ContentSecurityPolicyAttribute.cs b/BTCPayServer/Filters/ContentSecurityPolicyAttribute.cs new file mode 100644 index 000000000..45839d17e --- /dev/null +++ b/BTCPayServer/Filters/ContentSecurityPolicyAttribute.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Security; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace BTCPayServer.Filters +{ + public interface IContentSecurityPolicy : IFilterMetadata { } + public class ContentSecurityPolicyAttribute : Attribute, IActionFilter, IContentSecurityPolicy + { + public void OnActionExecuted(ActionExecutedContext context) + { + + } + + public bool AutoSelf { get; set; } = true; + public bool UnsafeInline { get; set; } = true; + public bool FixWebsocket { get; set; } = true; + public string FontSrc { get; set; } = null; + public string ImgSrc { get; set; } = null; + public string DefaultSrc { get; set; } + public string StyleSrc { get; set; } + public string ScriptSrc { get; set; } + + public void OnActionExecuting(ActionExecutingContext context) + { + if (context.IsEffectivePolicy(this)) + { + var policies = context.HttpContext.RequestServices.GetService(typeof(ContentSecurityPolicies)) as ContentSecurityPolicies; + if (policies == null) + return; + if (DefaultSrc != null) + { + policies.Add(new ConsentSecurityPolicy("default-src", DefaultSrc)); + } + if (UnsafeInline) + { + policies.Add(new ConsentSecurityPolicy("script-src", "'unsafe-inline'")); + } + if (!string.IsNullOrEmpty(FontSrc)) + { + policies.Add(new ConsentSecurityPolicy("font-src", FontSrc)); + } + + if (!string.IsNullOrEmpty(ImgSrc)) + { + policies.Add(new ConsentSecurityPolicy("img-src", ImgSrc)); + } + + if (!string.IsNullOrEmpty(StyleSrc)) + { + policies.Add(new ConsentSecurityPolicy("style-src", StyleSrc)); + } + + if (!string.IsNullOrEmpty(ScriptSrc)) + { + policies.Add(new ConsentSecurityPolicy("script-src", ScriptSrc)); + } + + if (FixWebsocket && AutoSelf) // Self does not match wss:// and ws:// :( + { + var request = context.HttpContext.Request; + + var url = string.Concat( + request.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "ws" : "wss", + "://", + request.Host.ToUriComponent(), + request.PathBase.ToUriComponent()); + policies.Add(new ConsentSecurityPolicy("connect-src", url)); + } + + context.HttpContext.Response.OnStarting(() => + { + if (!policies.HasRules) + return Task.CompletedTask; + if (AutoSelf) + { + bool hasSelf = false; + foreach (var group in policies.Rules.GroupBy(p => p.Name)) + { + hasSelf = group.Any(g => g.Value.Contains("'self'", StringComparison.OrdinalIgnoreCase)); + if (!hasSelf && !group.Any(g => g.Value.Contains("'none'", StringComparison.OrdinalIgnoreCase) || + g.Value.Contains("*", StringComparison.OrdinalIgnoreCase))) + { + policies.Add(new ConsentSecurityPolicy(group.Key, "'self'")); + hasSelf = true; + } + if (hasSelf) + { + foreach (var authorized in policies.Authorized) + { + policies.Add(new ConsentSecurityPolicy(group.Key, authorized)); + } + } + } + } + context.HttpContext.Response.SetHeader("Content-Security-Policy", policies.ToString()); + return Task.CompletedTask; + }); + } + } + } +} diff --git a/BTCPayServer/Filters/ReferrerPolicyAttribute.cs b/BTCPayServer/Filters/ReferrerPolicyAttribute.cs index 7e64a9f7e..829a68dc8 100644 --- a/BTCPayServer/Filters/ReferrerPolicyAttribute.cs +++ b/BTCPayServer/Filters/ReferrerPolicyAttribute.cs @@ -23,11 +23,7 @@ namespace BTCPayServer.Filters { if (context.IsEffectivePolicy(this)) { - var existing = context.HttpContext.Response.Headers["Referrer-Policy"].FirstOrDefault(); - if (existing != null && Value == null) - context.HttpContext.Response.Headers.Remove("Referrer-Policy"); - else - context.HttpContext.Response.Headers["Referrer-Policy"] = Value; + context.HttpContext.Response.SetHeaderOnStarting("Referrer-Policy", Value); } } } diff --git a/BTCPayServer/Filters/XContentTypeOptionsAttribute.cs b/BTCPayServer/Filters/XContentTypeOptionsAttribute.cs index 1b3b6ff1d..4f29bcd53 100644 --- a/BTCPayServer/Filters/XContentTypeOptionsAttribute.cs +++ b/BTCPayServer/Filters/XContentTypeOptionsAttribute.cs @@ -19,11 +19,7 @@ namespace BTCPayServer.Filters public string Value { get; set; } public void OnActionExecuting(ActionExecutingContext context) { - var existing = context.HttpContext.Response.Headers["X-Content-Type-Options"].FirstOrDefault(); - if (existing != null && Value == null) - context.HttpContext.Response.Headers.Remove("X-Content-Type-Options"); - else - context.HttpContext.Response.Headers["X-Content-Type-Options"] = Value; + context.HttpContext.Response.SetHeaderOnStarting("X-Content-Type-Options", Value); } } } diff --git a/BTCPayServer/Filters/XFrameOptionsAttribute.cs b/BTCPayServer/Filters/XFrameOptionsAttribute.cs index a736559ad..509943c7f 100644 --- a/BTCPayServer/Filters/XFrameOptionsAttribute.cs +++ b/BTCPayServer/Filters/XFrameOptionsAttribute.cs @@ -23,11 +23,7 @@ namespace BTCPayServer.Filters public void OnActionExecuting(ActionExecutingContext context) { - var existing = context.HttpContext.Response.Headers["X-Frame-Options"].FirstOrDefault(); - if (existing != null && Value == null) - context.HttpContext.Response.Headers.Remove("X-Frame-Options"); - else - context.HttpContext.Response.Headers["X-Frame-Options"] = Value; + context.HttpContext.Response.SetHeaderOnStarting("X-Frame-Options", Value); } } } diff --git a/BTCPayServer/Filters/XXSSProtectionAttribute.cs b/BTCPayServer/Filters/XXSSProtectionAttribute.cs index cfcdfe3f7..a929ba664 100644 --- a/BTCPayServer/Filters/XXSSProtectionAttribute.cs +++ b/BTCPayServer/Filters/XXSSProtectionAttribute.cs @@ -16,11 +16,7 @@ namespace BTCPayServer.Filters public void OnActionExecuting(ActionExecutingContext context) { - var existing = context.HttpContext.Response.Headers["X-XSS-Protection"].FirstOrDefault(); - if (existing != null) - context.HttpContext.Response.Headers.Remove("X-XSS-Protection"); - else - context.HttpContext.Response.Headers["X-XSS-Protection"] = "1; mode=block"; + context.HttpContext.Response.SetHeaderOnStarting("X-XSS-Protection", "1; mode=block"); } } diff --git a/BTCPayServer/HostedServices/CssThemeManager.cs b/BTCPayServer/HostedServices/CssThemeManager.cs index b0e32673b..1e240c940 100644 --- a/BTCPayServer/HostedServices/CssThemeManager.cs +++ b/BTCPayServer/HostedServices/CssThemeManager.cs @@ -11,6 +11,8 @@ using NBXplorer.Models; using System.Collections.Concurrent; using BTCPayServer.Events; using BTCPayServer.Services; +using Microsoft.AspNetCore.Mvc.Filters; +using BTCPayServer.Security; namespace BTCPayServer.HostedServices { @@ -50,6 +52,33 @@ namespace BTCPayServer.HostedServices } } + public class ContentSecurityPolicyCssThemeManager : Attribute, IActionFilter, IOrderedFilter + { + public int Order => 1001; + + public void OnActionExecuted(ActionExecutedContext context) + { + + } + + public void OnActionExecuting(ActionExecutingContext context) + { + var manager = context.HttpContext.RequestServices.GetService(typeof(CssThemeManager)) as CssThemeManager; + var policies = context.HttpContext.RequestServices.GetService(typeof(ContentSecurityPolicies)) as ContentSecurityPolicies; + if (manager != null && policies != null) + { + if(manager.CreativeStartUri != null && Uri.TryCreate(manager.CreativeStartUri, UriKind.Absolute, out var uri)) + { + policies.Clear(); + } + if (manager.BootstrapUri != null && Uri.TryCreate(manager.BootstrapUri, UriKind.Absolute, out uri)) + { + policies.Clear(); + } + } + } + } + public class CssThemeManagerHostedService : BaseAsyncService { private SettingsRepository _SettingsRepository; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 41509792b..08c06a9ea 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -104,6 +104,7 @@ namespace BTCPayServer.Hosting }); services.AddSingleton(); + services.Configure((o) => { o.Filters.Add(new ContentSecurityPolicyCssThemeManager()); }); services.AddSingleton(); services.AddSingleton, Payments.Bitcoin.BitcoinLikePaymentHandler>(); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 68e278241..a6181825e 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -39,6 +39,7 @@ using Microsoft.AspNetCore.Mvc.Cors.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core; using System.Net; using Meziantou.AspNetCore.BundleTagHelpers; +using BTCPayServer.Security; namespace BTCPayServer.Hosting { @@ -82,8 +83,16 @@ namespace BTCPayServer.Hosting o.Filters.Add(new XContentTypeOptionsAttribute("nosniff")); o.Filters.Add(new XXSSProtectionAttribute()); o.Filters.Add(new ReferrerPolicyAttribute("same-origin")); + o.Filters.Add(new ContentSecurityPolicyAttribute() + { + FontSrc = "'self' https://fonts.gstatic.com/", + ImgSrc = "'self' data:", + DefaultSrc = "'none'", + StyleSrc = "'self' 'unsafe-inline'", + ScriptSrc = "'self' 'unsafe-inline'" + }); }); - + services.TryAddScoped(); services.Configure(options => { options.Password.RequireDigit = false; diff --git a/BTCPayServer/Security/ContentSecurityPolicies.cs b/BTCPayServer/Security/ContentSecurityPolicies.cs new file mode 100644 index 000000000..3411ca65c --- /dev/null +++ b/BTCPayServer/Security/ContentSecurityPolicies.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BTCPayServer.Security +{ + public class ConsentSecurityPolicy + { + public ConsentSecurityPolicy(string name, string value) + { + _Value = value; + _Name = name; + } + + + private readonly string _Name; + public string Name + { + get + { + return _Name; + } + } + + private readonly string _Value; + public string Value + { + get + { + return _Value; + } + } + + + public override bool Equals(object obj) + { + ConsentSecurityPolicy item = obj as ConsentSecurityPolicy; + if (item == null) + return false; + return GetHashCode().Equals(item.GetHashCode()); + } + public static bool operator ==(ConsentSecurityPolicy a, ConsentSecurityPolicy b) + { + if (System.Object.ReferenceEquals(a, b)) + return true; + if (((object)a == null) || ((object)b == null)) + return false; + return a.GetHashCode() == b.GetHashCode(); + } + + public static bool operator !=(ConsentSecurityPolicy a, ConsentSecurityPolicy b) + { + return !(a == b); + } + + public override int GetHashCode() + { + return HashCode.Combine(Name, Value); + } + } + public class ContentSecurityPolicies + { + public ContentSecurityPolicies() + { + + } + HashSet _Policies = new HashSet(); + public void Add(ConsentSecurityPolicy policy) + { + if (_Policies.Any(p => p.Name == policy.Name && p.Value == policy.Name)) + return; + _Policies.Add(policy); + } + + public IEnumerable Rules => _Policies; + public bool HasRules => _Policies.Count != 0; + + public override string ToString() + { + StringBuilder value = new StringBuilder(); + bool firstGroup = true; + foreach(var group in Rules.GroupBy(r => r.Name)) + { + if (!firstGroup) + { + value.Append(';'); + } + List values = new List(); + values.Add(group.Key); + foreach (var v in group) + { + values.Add(v.Value); + } + foreach(var i in authorized) + { + values.Add(i); + } + value.Append(String.Join(" ", values.OfType().ToArray())); + firstGroup = false; + } + return value.ToString(); + } + + internal void Clear() + { + authorized.Clear(); + _Policies.Clear(); + } + + HashSet authorized = new HashSet(); + internal void AddAllAuthorized(string v) + { + authorized.Add(v); + } + + public IEnumerable Authorized => authorized; + } +}