Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Admin/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
2 changes: 2 additions & 0 deletions src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

CreateHostBuilder(args).Build().Run();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,12 @@ public class RequestSMAccessRequestModel
[Required]
public Guid OrganizationId { get; set; }
[Required(ErrorMessage = "Add a note is a required field")]
[StringLength(MaxEmailContentLength)]
public string EmailContent { get; set; }

/// <summary>
/// The note is interpolated into an email sent to every organization admin, so it is bounded
/// to keep both the email and the server-side sanitization work proportionate.
/// </summary>
public const int MaxEmailContentLength = 1000;
}
2 changes: 2 additions & 0 deletions src/Billing/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
92 changes: 81 additions & 11 deletions src/Core/Utilities/CoreHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,24 +508,94 @@ public static string PreventEmailAutoLinking(string value)

/// <summary>
/// Sanitizes a value for display in an email by neutralizing anything that looks like an
/// address or link (e.g. "@" and "scheme://"). It deliberately does NOT HTML-encode the
/// address or link (e.g. "@", ".x" and "scheme://"). It deliberately does NOT HTML-encode the
/// result: the mail templates are rendered by Handlebars, which HTML-encodes interpolated
/// values ({{ }}) by default. Encoding here as well produced double-encoded output.
/// </summary>
/// <remarks>
/// This is a single left-to-right pass and runs in time linear to the input length, so it is
/// safe to call on caller-supplied input. A "scheme://" is any run of word characters
/// immediately followed by "://", or a "://" at the very start of the string. If stripping
/// one exposes another (e.g. "a:/b:///"), that one is stripped as well.
/// </remarks>
public static string SanitizeForEmail(string value)
{
var cleanedValue = value.Replace("@", "[at]");
var regexOptions = RegexOptions.CultureInvariant |
RegexOptions.Singleline |
RegexOptions.IgnoreCase;
cleanedValue = Regex.Replace(cleanedValue, @"(\.\w)",
m => string.Concat("[dot]", m.ToString().Last()), regexOptions);
while (Regex.IsMatch(cleanedValue, @"((^|\b)(\w*)://)", regexOptions))
if (string.IsNullOrEmpty(value))
{
return value;
}

var builder = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
switch (c)
{
case '@':
builder.Append("[at]");
break;
case '.' when i + 1 < value.Length && IsWordChar(value[i + 1]):
builder.Append("[dot]");
break;
default:
builder.Append(c);
break;
}

StripTrailingUrlScheme(builder);
}

return builder.ToString();
}

/// <summary>
/// If <paramref name="builder"/> ends with "://" that is either at the start of the buffer or
/// preceded by a word character, removes the "://" together with the word run before it.
/// </summary>
private static void StripTrailingUrlScheme(StringBuilder builder)
{
const string separator = "://";
var separatorStart = builder.Length - separator.Length;
if (separatorStart < 0)
{
return;
}

for (var j = 0; j < separator.Length; j++)
{
if (builder[separatorStart + j] != separator[j])
{
return;
}
}

if (separatorStart > 0 && !IsWordChar(builder[separatorStart - 1]))
{
return;
}

var schemeStart = separatorStart;
while (schemeStart > 0 && IsWordChar(builder[schemeStart - 1]))
{
schemeStart--;
}

builder.Length = schemeStart;
}

/// <summary>
/// Mirrors the regular expression <c>\w</c> class: letters, decimal digits, non-spacing marks
/// and connector punctuation (which includes "_").
/// </summary>
private static bool IsWordChar(char c)
{
if (char.IsLetterOrDigit(c))
{
cleanedValue = Regex.Replace(cleanedValue, @"((^|\b)(\w*)://)",
string.Empty, regexOptions);
return true;
}
return cleanedValue;

var category = CharUnicodeInfo.GetUnicodeCategory(c);
return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation;
}

public static string DateTimeToTableStorageKey(DateTime? date = null)
Expand Down
27 changes: 27 additions & 0 deletions src/Core/Utilities/RegexDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Bit.Core.Utilities;

/// <summary>
/// Process-wide defaults for <see cref="System.Text.RegularExpressions.Regex"/>.
/// </summary>
/// <remarks>
/// Deliberately kept free of any static <c>Regex</c> fields: the default match timeout is read once,
/// when the <c>Regex</c> type is first initialized, so it must be configured before any regular
/// expression is constructed anywhere in the process.
/// </remarks>
public static class RegexDefaults
{
/// <summary>
/// The timeout applied to every <c>Regex</c> that does not specify its own.
/// </summary>
public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(5);

/// <summary>
/// Configures a process-wide default <c>Regex</c> match timeout as defence in depth against
/// catastrophic backtracking on untrusted input (CWE-1333). Call this as the very first
/// statement of <c>Main</c>, before anything else has a chance to construct a <c>Regex</c>.
/// </summary>
public static void ConfigureDefaultMatchTimeout()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ QUESTION: Were the Sso and Scim hosts left out on purpose? Both reference Core, but neither Main calls ConfigureDefaultMatchTimeout().

Details and fix

The PR description says the timeout is set "in each service host's Main". That is true for the eight hosts under src/, but bitwarden_license/src/Sso/Program.cs and bitwarden_license/src/Scim/Program.cs are unchanged. Both reference Core.csproj, and both already have using Bit.Core.Utilities;. So those two processes still run every Regex with no timeout, and SSO handles external IdP input.

If the omission is not intentional, add this as the first statement of each Main:

RegexDefaults.ConfigureDefaultMatchTimeout();

code-review

{
AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", DefaultMatchTimeout);
}
}
2 changes: 2 additions & 0 deletions src/Events/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
2 changes: 2 additions & 0 deletions src/EventsProcessor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
5 changes: 5 additions & 0 deletions src/Icons/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ public class Program
{
public static void Main(string[] args)
{
// This project does not reference Core, so this mirrors Bit.Core.Utilities.RegexDefaults:
// a process-wide Regex match timeout as defence in depth against catastrophic
// backtracking (CWE-1333). It must run before any Regex is constructed.
AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(5));

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
2 changes: 2 additions & 0 deletions src/Identity/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class Program
{
public static void Main(string[] args)
{
RegexDefaults.ConfigureDefaultMatchTimeout();

CreateHostBuilder(args)
.Build()
.Run();
Expand Down
5 changes: 5 additions & 0 deletions src/Notifications/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ public class Program
{
public static void Main(string[] args)
{
// This project does not reference Core, so this mirrors Bit.Core.Utilities.RegexDefaults:
// a process-wide Regex match timeout as defence in depth against catastrophic
// backtracking (CWE-1333). It must run before any Regex is constructed.
AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(5));

Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.SecretsManager.Models.Request;
using Xunit;

namespace Bit.Api.Test.SecretsManager.Models.Request;

public class RequestSMAccessRequestModelTests
{
[Theory]
[InlineData("I need access to the deployment secrets.")]
[InlineData("x")]
public void Validate_WithReasonableNote_ReturnsNoErrors(string emailContent)
{
var results = Validate(BuildModel(emailContent));

Assert.Empty(results);
}

[Fact]
public void Validate_WithNoteAtMaxLength_ReturnsNoErrors()
{
var results = Validate(BuildModel(new string('a', RequestSMAccessRequestModel.MaxEmailContentLength)));

Assert.Empty(results);
}

[Fact]
public void Validate_WithNoteOverMaxLength_ReturnsError()
{
var results = Validate(BuildModel(new string('a', RequestSMAccessRequestModel.MaxEmailContentLength + 1)));

var error = Assert.Single(results);
Assert.Contains(nameof(RequestSMAccessRequestModel.EmailContent), error.MemberNames);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Validate_WithMissingNote_ReturnsError(string? emailContent)
{
var results = Validate(BuildModel(emailContent));

var error = Assert.Single(results);
Assert.Contains(nameof(RequestSMAccessRequestModel.EmailContent), error.MemberNames);
}

private static RequestSMAccessRequestModel BuildModel(string? emailContent) => new()
{
OrganizationId = Guid.NewGuid(),
EmailContent = emailContent!,
};

private static List<ValidationResult> Validate(RequestSMAccessRequestModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results;
}
}
56 changes: 55 additions & 1 deletion test/Core.Test/Utilities/CoreHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Diagnostics;
using System.Text;
using AutoFixture;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Enums.Provider;
Expand Down Expand Up @@ -486,11 +487,64 @@ public void ReplaceWhiteSpace_Success(string email)
[InlineData("user@example.com", "user[at]example[dot]com")]
[InlineData("https://bitwarden.com", "bitwarden[dot]com")]
[InlineData("ftp://files.example.org", "files[dot]example[dot]org")]
[InlineData("HTTPS://BITWARDEN.COM", "BITWARDEN[dot]COM")]
[InlineData("see https://a.io and http://b.io", "see a[dot]io and b[dot]io")]
[InlineData("my-app://open", "my-open")]
[InlineData("under_score://x", "x")]
[InlineData("://leading", "leading")]
[InlineData("a ://b", "a ://b")]
[InlineData("trailing.", "trailing.")]
[InlineData("a..b", "a.[dot]b")]
[InlineData("a@://", "a[at]://")]
[InlineData("Acme Corp", "Acme Corp")]
[InlineData("Ünïcødé://näme", "näme")]
[InlineData("", "")]
public void SanitizeForEmail_NeutralizesAddressesAndLinks(string value, string expected)
{
Assert.Equal(expected, CoreHelpers.SanitizeForEmail(value));
}

[Theory]
[InlineData("http://://bitwarden.com", "bitwarden[dot]com")]
[InlineData("a:/b:///", "")]
[InlineData("xc://ab://", "")]
public void SanitizeForEmail_StripsSchemesExposedByEarlierStripping(string value, string expected)
{
Assert.Equal(expected, CoreHelpers.SanitizeForEmail(value));
}

[Fact]
public void SanitizeForEmail_LargeInput_CompletesInLinearTime()
{
// Before the rewrite this shape of input required one full regex pass per "://",
// i.e. quadratic time; at this size that took minutes. Linear processing takes
// milliseconds, so a generous bound still cleanly separates the two.
var value = "a://" + string.Concat(Enumerable.Repeat("://", 100_000));
var stopwatch = Stopwatch.StartNew();

var result = CoreHelpers.SanitizeForEmail(value);

stopwatch.Stop();
Assert.Equal(string.Empty, result);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2),
$"SanitizeForEmail took {stopwatch.Elapsed} on {value.Length} characters");
}

[Fact]
public void SanitizeForEmail_LargeMixedInput_CompletesInLinearTime()
{
var value = string.Concat(Enumerable.Repeat("user@example.com https://bitwarden.com/x ", 25_000));
var stopwatch = Stopwatch.StartNew();

var result = CoreHelpers.SanitizeForEmail(value);

stopwatch.Stop();
Assert.DoesNotContain("@", result);
Assert.DoesNotContain("://", result);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2),
$"SanitizeForEmail took {stopwatch.Elapsed} on {value.Length} characters");
}

[Theory]
[InlineData("<script>alert('x')</script>")]
[InlineData("Org & Co \"quoted\"")]
Expand Down
Loading