Add CSP at the website level (#2863)

This commit is contained in:
Nicolas Dorier 2021-09-09 21:51:28 +09:00 committed by GitHub
parent c39f1341aa
commit fc4e47cec6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 224 additions and 90 deletions

View file

@ -134,7 +134,7 @@ namespace BTCPayServer.Tests
config.AppendLine($"torrcfile={TestUtils.GetTestDataFullPath("Tor/torrc")}"); config.AppendLine($"torrcfile={TestUtils.GetTestDataFullPath("Tor/torrc")}");
config.AppendLine($"socksendpoint={SocksEndpoint}"); config.AppendLine($"socksendpoint={SocksEndpoint}");
config.AppendLine($"debuglog=debug.log"); config.AppendLine($"debuglog=debug.log");
config.AppendLine($"nocsp={NoCSP.ToString().ToLowerInvariant()}");
if (!string.IsNullOrEmpty(SSHPassword) && string.IsNullOrEmpty(SSHKeyFile)) if (!string.IsNullOrEmpty(SSHPassword) && string.IsNullOrEmpty(SSHKeyFile))
config.AppendLine($"sshpassword={SSHPassword}"); config.AppendLine($"sshpassword={SSHPassword}");
@ -283,6 +283,8 @@ namespace BTCPayServer.Tests
public string SSHPassword { get; internal set; } public string SSHPassword { get; internal set; }
public string SSHKeyFile { get; internal set; } public string SSHKeyFile { get; internal set; }
public string SSHConnection { get; set; } public string SSHConnection { get; set; }
public bool NoCSP { get; set; }
public T GetController<T>(string userId = null, string storeId = null, bool isAdmin = false) where T : Controller public T GetController<T>(string userId = null, string storeId = null, bool isAdmin = false) where T : Controller
{ {
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();

View file

@ -38,6 +38,7 @@ namespace BTCPayServer.Tests
public async Task StartAsync() public async Task StartAsync()
{ {
Server.PayTester.NoCSP = true;
await Server.StartAsync(); await Server.StartAsync();
var windowSize = (Width: 1200, Height: 1000); var windowSize = (Width: 1200, Height: 1000);

View file

@ -67,7 +67,7 @@ else
@if (!disabled) @if (!disabled)
{ {
<script type="text/javascript"> <script type="text/javascript" csp-sha256>
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2; var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;

View file

@ -28,6 +28,7 @@ namespace BTCPayServer.Configuration
app.Option("--chains | -c", $"Chains to support as a comma separated (default: btc; available: {chains})", CommandOptionType.SingleValue); app.Option("--chains | -c", $"Chains to support as a comma separated (default: btc; available: {chains})", CommandOptionType.SingleValue);
app.Option("--postgres", $"Connection string to a PostgreSQL database", CommandOptionType.SingleValue); app.Option("--postgres", $"Connection string to a PostgreSQL database", CommandOptionType.SingleValue);
app.Option("--mysql", $"Connection string to a MySQL database", CommandOptionType.SingleValue); app.Option("--mysql", $"Connection string to a MySQL database", CommandOptionType.SingleValue);
app.Option("--nocsp", $"Disable CSP (default false)", CommandOptionType.BoolValue);
app.Option("--sqlitefile", $"File name to an SQLite database file inside the data directory", CommandOptionType.SingleValue); app.Option("--sqlitefile", $"File name to an SQLite database file inside the data directory", CommandOptionType.SingleValue);
app.Option("--externalservices", $"Links added to external services inside Server Settings / Services under the format service1:path2;service2:path2.(default: empty)", CommandOptionType.SingleValue); app.Option("--externalservices", $"Links added to external services inside Server Settings / Services under the format service1:path2;service2:path2.(default: empty)", CommandOptionType.SingleValue);
app.Option("--bundlejscss", $"Bundle JavaScript and CSS files for better performance (default: true)", CommandOptionType.SingleValue); app.Option("--bundlejscss", $"Bundle JavaScript and CSS files for better performance (default: true)", CommandOptionType.SingleValue);

View file

@ -7,13 +7,32 @@ using Microsoft.AspNetCore.Mvc.Filters;
namespace BTCPayServer.Filters namespace BTCPayServer.Filters
{ {
public interface IContentSecurityPolicy : IFilterMetadata { } public interface IContentSecurityPolicy : IFilterMetadata { }
public enum CSPTemplate
{
AntiXSS
}
public class ContentSecurityPolicyAttribute : Attribute, IActionFilter, IContentSecurityPolicy public class ContentSecurityPolicyAttribute : Attribute, IActionFilter, IContentSecurityPolicy
{ {
public ContentSecurityPolicyAttribute()
{
}
public ContentSecurityPolicyAttribute(CSPTemplate template)
{
if (template == CSPTemplate.AntiXSS)
{
AutoSelf = false;
FixWebsocket = false;
UnsafeInline = false;
ScriptSrc = "'self' 'unsafe-eval'"; // unsafe-eval needed for vue
}
}
public void OnActionExecuted(ActionExecutedContext context) public void OnActionExecuted(ActionExecutedContext context)
{ {
} }
public bool Enabled { get; set; } = true;
public bool AutoSelf { get; set; } = true; public bool AutoSelf { get; set; } = true;
public bool UnsafeInline { get; set; } = true; public bool UnsafeInline { get; set; } = true;
public bool FixWebsocket { get; set; } = true; public bool FixWebsocket { get; set; } = true;
@ -22,83 +41,79 @@ namespace BTCPayServer.Filters
public string DefaultSrc { get; set; } public string DefaultSrc { get; set; }
public string StyleSrc { get; set; } public string StyleSrc { get; set; }
public string ScriptSrc { get; set; } public string ScriptSrc { get; set; }
public string ManifestSrc { get; set; }
public void OnActionExecuting(ActionExecutingContext context) public void OnActionExecuting(ActionExecutingContext context)
{ {
if (context.IsEffectivePolicy<IContentSecurityPolicy>(this)) if (!context.IsEffectivePolicy<IContentSecurityPolicy>(this) || !Enabled)
return;
var policies = context.HttpContext.RequestServices.GetService(typeof(ContentSecurityPolicies)) as ContentSecurityPolicies;
if (policies == null)
return;
if (DefaultSrc != null)
{ {
var policies = context.HttpContext.RequestServices.GetService(typeof(ContentSecurityPolicies)) as ContentSecurityPolicies; policies.Add(new ConsentSecurityPolicy("default-src", DefaultSrc));
if (policies == null) }
return; if (UnsafeInline)
if (DefaultSrc != null) {
{ policies.Add(new ConsentSecurityPolicy("script-src", "'unsafe-inline'"));
policies.Add(new ConsentSecurityPolicy("default-src", DefaultSrc)); }
} if (!string.IsNullOrEmpty(FontSrc))
if (UnsafeInline) {
{ policies.Add(new ConsentSecurityPolicy("font-src", FontSrc));
policies.Add(new ConsentSecurityPolicy("script-src", "'unsafe-inline'")); }
} if (!string.IsNullOrEmpty(ManifestSrc))
if (!string.IsNullOrEmpty(FontSrc)) {
{ policies.Add(new ConsentSecurityPolicy("manifest-src", FontSrc));
policies.Add(new ConsentSecurityPolicy("font-src", FontSrc)); }
}
if (!string.IsNullOrEmpty(ImgSrc)) if (!string.IsNullOrEmpty(ImgSrc))
{ {
policies.Add(new ConsentSecurityPolicy("img-src", ImgSrc)); policies.Add(new ConsentSecurityPolicy("img-src", ImgSrc));
} }
if (!string.IsNullOrEmpty(StyleSrc)) if (!string.IsNullOrEmpty(StyleSrc))
{ {
policies.Add(new ConsentSecurityPolicy("style-src", StyleSrc)); policies.Add(new ConsentSecurityPolicy("style-src", StyleSrc));
} }
if (!string.IsNullOrEmpty(ScriptSrc)) if (!string.IsNullOrEmpty(ScriptSrc))
{ {
policies.Add(new ConsentSecurityPolicy("script-src", ScriptSrc)); policies.Add(new ConsentSecurityPolicy("script-src", ScriptSrc));
} }
if (FixWebsocket && AutoSelf) // Self does not match wss:// and ws:// :( if (FixWebsocket && AutoSelf) // Self does not match wss:// and ws:// :(
{ {
var request = context.HttpContext.Request; var request = context.HttpContext.Request;
var url = string.Concat( var url = string.Concat(
request.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "ws" : "wss", request.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "ws" : "wss",
"://", "://",
request.Host.ToUriComponent(), request.Host.ToUriComponent(),
request.PathBase.ToUriComponent()); request.PathBase.ToUriComponent());
policies.Add(new ConsentSecurityPolicy("connect-src", url)); policies.Add(new ConsentSecurityPolicy("connect-src", url));
} }
context.HttpContext.Response.OnStarting(() => context.HttpContext.Response.OnStarting(() =>
{
if (!policies.HasRules)
return Task.CompletedTask;
if (AutoSelf)
{ {
if (!policies.HasRules) bool hasSelf = false;
return Task.CompletedTask; foreach (var group in policies.Rules.GroupBy(p => p.Name))
if (AutoSelf)
{ {
bool hasSelf = false; hasSelf = group.Any(g => g.Value.Contains("'self'", StringComparison.OrdinalIgnoreCase));
foreach (var group in policies.Rules.GroupBy(p => p.Name)) if (!hasSelf && !group.Any(g => g.Value.Contains("'none'", StringComparison.OrdinalIgnoreCase) ||
g.Value.Contains("*", StringComparison.OrdinalIgnoreCase)))
{ {
hasSelf = group.Any(g => g.Value.Contains("'self'", StringComparison.OrdinalIgnoreCase)); policies.Add(new ConsentSecurityPolicy(group.Key, "'self'"));
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; context.HttpContext.Response.SetHeader("Content-Security-Policy", policies.ToString());
}); return Task.CompletedTask;
} });
} }
} }
} }

View file

@ -114,14 +114,8 @@ namespace BTCPayServer.Hosting
o.Filters.Add(new XXSSProtectionAttribute()); o.Filters.Add(new XXSSProtectionAttribute());
o.Filters.Add(new ReferrerPolicyAttribute("same-origin")); o.Filters.Add(new ReferrerPolicyAttribute("same-origin"));
o.ModelBinderProviders.Insert(0, new ModelBinders.DefaultModelBinderProvider()); o.ModelBinderProviders.Insert(0, new ModelBinders.DefaultModelBinderProvider());
//o.Filters.Add(new ContentSecurityPolicyAttribute() if (!Configuration.GetOrDefault<bool>("nocsp", false))
//{ o.Filters.Add(new ContentSecurityPolicyAttribute(CSPTemplate.AntiXSS));
// FontSrc = "'self' https://fonts.gstatic.com/",
// ImgSrc = "'self' data:",
// DefaultSrc = "'none'",
// StyleSrc = "'self' 'unsafe-inline'",
// ScriptSrc = "'self' 'unsafe-inline'"
//});
}) })
.ConfigureApiBehaviorOptions(options => .ConfigureApiBehaviorOptions(options =>
{ {

View file

@ -9,6 +9,8 @@ namespace BTCPayServer.Security
{ {
public ConsentSecurityPolicy(string name, string value) public ConsentSecurityPolicy(string name, string value)
{ {
if (value.Contains(';', StringComparison.OrdinalIgnoreCase))
throw new FormatException();
_Value = value; _Value = value;
_Name = name; _Name = name;
} }
@ -67,6 +69,10 @@ namespace BTCPayServer.Security
} }
readonly HashSet<ConsentSecurityPolicy> _Policies = new HashSet<ConsentSecurityPolicy>(); readonly HashSet<ConsentSecurityPolicy> _Policies = new HashSet<ConsentSecurityPolicy>();
public void Add(string name, string value)
{
Add(new ConsentSecurityPolicy(name, value));
}
public void Add(ConsentSecurityPolicy policy) public void Add(ConsentSecurityPolicy policy)
{ {
if (_Policies.Any(p => p.Name == policy.Name && p.Value == policy.Name)) if (_Policies.Any(p => p.Name == policy.Name && p.Value == policy.Name))
@ -87,16 +93,12 @@ namespace BTCPayServer.Security
{ {
value.Append(';'); value.Append(';');
} }
List<string> values = new List<string>(); HashSet<string> values = new HashSet<string>();
values.Add(group.Key); values.Add(group.Key);
foreach (var v in group) foreach (var v in group)
{ {
values.Add(v.Value); values.Add(v.Value);
} }
foreach (var i in authorized)
{
values.Add(i);
}
value.Append(String.Join(" ", values.OfType<object>().ToArray())); value.Append(String.Join(" ", values.OfType<object>().ToArray()));
firstGroup = false; firstGroup = false;
} }
@ -105,16 +107,7 @@ namespace BTCPayServer.Security
internal void Clear() internal void Clear()
{ {
authorized.Clear();
_Policies.Clear(); _Policies.Clear();
} }
readonly HashSet<string> authorized = new HashSet<string>();
internal void AddAllAuthorized(string v)
{
authorized.Add(v);
}
public IEnumerable<string> Authorized => authorized;
} }
} }

128
BTCPayServer/TagHelpers.cs Normal file
View file

@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Security;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Razor.TagHelpers;
using NBitcoin;
using NBitcoin.Crypto;
namespace BTCPayServer.TagHelpers
{
[HtmlTargetElement("srv-model")]
public class SrvModel : TagHelper
{
private readonly Safe _safe;
private readonly ContentSecurityPolicies _csp;
public SrvModel(Safe safe, ContentSecurityPolicies csp)
{
_safe = safe;
_csp = csp;
}
public string VarName { get; set; } = "srvModel";
public object Model { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "script";
output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.Add(new TagHelperAttribute("type", "text/javascript"));
var nonce = RandomUtils.GetUInt256().ToString().Substring(0, 32);
output.Attributes.Add(new TagHelperAttribute("nonce", nonce));
_csp.Add("script-src", $"'nonce-{nonce}'");
output.Content.SetHtmlContent($"var {VarName} = {_safe.Json(Model)};");
}
}
/// <summary>
/// Add a nonce-* so the inline-script can pass CSP rule when they are rendered server-side
/// </summary>
[HtmlTargetElement("script")]
public class CSPInlineScriptTagHelper : TagHelper
{
private readonly ContentSecurityPolicies _csp;
public CSPInlineScriptTagHelper(ContentSecurityPolicies csp)
{
_csp = csp;
}
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (output.Attributes.ContainsName("src"))
return;
if (output.Attributes.TryGetAttribute("type", out var attr))
{
if (attr.Value?.ToString() != "text/javascript")
return;
}
if (output.Attributes.ContainsName("csp-sha256"))
{
var sha = CSPEventTagHelper.GetSha256((await output.GetChildContentAsync(true)).GetContent());
_csp.Add("script-src", $"'sha256-{sha}'");
output.Attributes.RemoveAll("csp-sha256");
}
else
{
var nonce = RandomUtils.GetUInt256().ToString().Substring(0, 32);
output.Attributes.Add(new TagHelperAttribute("nonce", nonce));
_csp.Add("script-src", $"'nonce-{nonce}'");
}
}
}
/// <summary>
/// Add 'unsafe-hashes' and sha256- to allow inline event handlers in CSP
/// </summary>
[HtmlTargetElement(Attributes = "onclick")]
[HtmlTargetElement(Attributes = "onkeypress")]
[HtmlTargetElement(Attributes = "onchange")]
[HtmlTargetElement(Attributes = "onsubmit")]
[HtmlTargetElement(Attributes = "href")]
public class CSPEventTagHelper : TagHelper
{
public const string EventNames = "onclick,onkeypress,onchange,onsubmit";
private readonly ContentSecurityPolicies _csp;
readonly static HashSet<string> EventSet = EventNames.Split(',')
.ToHashSet();
public CSPEventTagHelper(ContentSecurityPolicies csp)
{
_csp = csp;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
foreach (var attr in output.Attributes)
{
var n = attr.Name.ToLowerInvariant();
if (EventSet.Contains(n))
{
Allow(attr.Value.ToString());
}
else if (n == "href")
{
var v = attr.Value.ToString();
if (v.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
{
Allow(v);
}
}
}
}
private void Allow(string v)
{
var sha = GetSha256(v);
_csp.Add("script-src", $"'unsafe-hashes'");
_csp.Add("script-src", $"'sha256-{sha}'");
}
public static string GetSha256(string script)
{
return Convert.ToBase64String(Hashes.SHA256(Encoding.UTF8.GetBytes(script.Replace("\r\n", "\n", StringComparison.Ordinal))));
}
}
}

View file

@ -1,7 +1,7 @@
@model (Dictionary<string, object> Items, int Level) @model (Dictionary<string, object> Items, int Level)
@functions{ @functions{
void DisplayValue(object value) async Task DisplayValue(object value)
{ {
if (value is string str && str.StartsWith("http")) if (value is string str && str.StartsWith("http"))
{ {
@ -25,7 +25,7 @@
<th class="w-150px">@key</th> <th class="w-150px">@key</th>
} }
<td> <td>
@{ DisplayValue(value); } @{ await DisplayValue(value); }
</td> </td>
} }
else if (value is Dictionary<string, object>subItems) else if (value is Dictionary<string, object>subItems)
@ -35,7 +35,7 @@
{ {
<th class="w-150px">@key</th> <th class="w-150px">@key</th>
<td> <td>
@{ DisplayValue(subItems.First().Value); } @{ await DisplayValue(subItems.First().Value); }
</td> </td>
} }
else else