perf(backend): cache additional regex patterns (#89)

* perf(backend): cache regex patterns for better performance

- TransmissionBase: add static VersionRegex, share with Transmission
- SearchCriteriaBase: cache RepeatingPlusRegex
- SearchMovieComparer: cache QueryYearRegex
- XbmcMetadata: cache WatchedRegex

Avoids regex compilation on each method call.

Partially addresses #36

* fix(security): add regex timeout to prevent ReDoS vulnerabilities

All cached regex patterns now include TimeSpan.FromSeconds(1) timeout
to prevent potential denial of service from malicious input patterns.

---------

Co-authored-by: admin <admin@ardentleatherworks.com>
This commit is contained in:
Cody Kickertz 2025-12-21 10:38:37 -06:00 committed by GitHub
parent b17381f53f
commit 168ea24266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 11 additions and 6 deletions

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
@ -78,7 +77,7 @@ protected override ValidationFailure ValidateVersion()
_logger.Debug("Transmission version information: {0}", versionString);
var versionResult = Regex.Match(versionString, @"(?<!\(|(\d|\.)+)(\d|\.)+(?!\)|(\d|\.)+)").Value;
var versionResult = VersionRegex.Match(versionString).Value;
var version = Version.Parse(versionResult);
if (version < new Version(2, 40))

View file

@ -19,6 +19,8 @@ namespace NzbDrone.Core.Download.Clients.Transmission
{
public abstract class TransmissionBase : TorrentClientBase<TransmissionSettings>
{
protected static readonly Regex VersionRegex = new Regex(@"(?<!\(|(\d|\.)+)(\d|\.)+(?!\)|(\d|\.)+)", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public abstract bool SupportsLabels { get; }
protected readonly ITransmissionProxy _proxy;
@ -331,7 +333,7 @@ protected bool HasClientVersion(int major, int minor)
{
var rawVersion = _proxy.GetClientVersion(Settings);
var versionResult = Regex.Match(rawVersion, @"(?<!\(|(\d|\.)+)(\d|\.)+(?!\)|(\d|\.)+)").Value;
var versionResult = VersionRegex.Match(rawVersion).Value;
var clientVersion = Version.Parse(versionResult);
return clientVersion >= new Version(major, minor);

View file

@ -51,6 +51,7 @@ public XbmcMetadata(IDetectXbmcNfo detectNfo,
private static readonly Regex MovieImagesRegex = new Regex(@"^(?<type>poster|banner|fanart|clearart|discart|keyart|landscape|logo|backdrop|clearlogo)\.(?:png|jpe?g)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MovieFileImageRegex = new Regex(@"(?<type>-thumb|-poster|-banner|-fanart|-clearart|-discart|-keyart|-landscape|-logo|-backdrop|-clearlogo)\.(?:png|jpe?g)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex WatchedRegex = new Regex("<watched>true</watched>", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public override string Name => "Kodi (XBMC) / Emby";
@ -489,7 +490,7 @@ private bool GetExistingWatchedStatus(Movie movie, string movieFilePath)
var fileContent = _diskProvider.ReadAllText(fullPath);
return Regex.IsMatch(fileContent, "<watched>true</watched>");
return WatchedRegex.IsMatch(fileContent);
}
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
@ -12,6 +13,7 @@ public abstract class SearchCriteriaBase
private static readonly Regex SpecialCharacter = new Regex(@"['.\u0060\u00B4\u2018\u2019]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NonWord = new Regex(@"[\W]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex BeginningThe = new Regex(@"^the\s", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RepeatingPlusRegex = new Regex(@"\+{2,}", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public Movie Movie { get; set; }
public List<string> SceneTitles { get; set; }
@ -32,7 +34,7 @@ public static string GetCleanSceneTitle(string title)
cleanTitle = NonWord.Replace(cleanTitle, "+");
// remove any repeating +s
cleanTitle = Regex.Replace(cleanTitle, @"\+{2,}", "+");
cleanTitle = RepeatingPlusRegex.Replace(cleanTitle, "+");
cleanTitle = cleanTitle.RemoveAccent();
return cleanTitle.Trim('+', ' ');
}

View file

@ -11,6 +11,7 @@ public class SearchMovieComparer : IComparer<Movie>
private static readonly Regex RegexCleanPunctuation = new Regex("[-._:]", RegexOptions.Compiled);
private static readonly Regex RegexCleanCountryYearPostfix = new Regex(@"(?<=.+)( \([A-Z]{2}\)| \(\d{4}\)| \([A-Z]{2}\) \(\d{4}\))$", RegexOptions.Compiled);
private static readonly Regex ArticleRegex = new Regex(@"^(a|an|the)\s", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex QueryYearRegex = new Regex(@"^(?<query>.+)\s+(?:\((?<year>\d{4})\)|(?<year>\d{4}))$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public string SearchQuery { get; private set; }
@ -21,7 +22,7 @@ public SearchMovieComparer(string searchQuery)
{
SearchQuery = searchQuery;
var match = Regex.Match(SearchQuery, @"^(?<query>.+)\s+(?:\((?<year>\d{4})\)|(?<year>\d{4}))$");
var match = QueryYearRegex.Match(SearchQuery);
if (match.Success)
{
_searchQueryWithoutYear = match.Groups["query"].Value.ToLowerInvariant();