Skip to content
Merged
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
185 changes: 172 additions & 13 deletions Core/Resgrid.Framework/StringHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,167 @@ public static string SanitizeHtmlInString(string source)
if (string.IsNullOrWhiteSpace(source))
return source;

var cleanHtml = SanitizeHtml(source);
if (cleanHtml == null)
{
// The text itself is not logged: it is call, message or record content.
Logging.LogError($"Rich text of {source.Length} characters held only comments, styles or document tags; stored the placeholder.");
return "Invalid HTML in Source String";
}

if (!String.IsNullOrWhiteSpace(cleanHtml))
return cleanHtml.Trim();

//Logging.LogError("Following string could not be sanitized: " + source);
return "Invalid HTML in Source String";
}

/// <summary>
/// Stored rich text (call nature and notes, messages, department notes, trainings, protocols, documents, calendar
/// items, logs, contact notes...) made safe to write into a page with Html.Raw: the same allow-list as
/// <see cref="SanitizeHtmlInString"/>, so scripts, event handlers, styles, frames and script URLs never reach the
/// browser, whichever path stored the value (web form, v4 API, inbound e-mail, import, workers) and however old it
/// is. Unlike the save-time sanitizer it never substitutes a placeholder: nothing left to show is an empty string.
/// Text without a '&lt;' cannot hold markup and is returned unchanged; plain text that only mentions angle brackets
/// ("pt &lt;unconscious&gt; bp 80", which the sanitizer would delete with its text) is shown literally.
/// </summary>
public static string SanitizeHtmlForDisplay(string source)
{
if (string.IsNullOrWhiteSpace(source))
return string.Empty;
if (source.IndexOf('<') < 0)
return source;

try
{
if (!LooksLikeHtml(source))
return EncodeAngleBrackets(source);
return SanitizeHtml(source)?.Trim() ?? string.Empty;
}
catch (Exception ex)
{
// Fail closed: the text is shown encoded rather than as markup.
Logging.LogException(ex, "Rich text could not be sanitized for display; encoding it instead.");
return System.Net.WebUtility.HtmlEncode(source);
}
}

/// <summary>
/// Free text about to be stored where pages, apps or exports may treat it as HTML: markup goes through the same
/// allow-list as <see cref="SanitizeHtmlInString"/>, so a script, handler, frame or script URL is never stored, while
/// plain text (including text that mentions "&lt;" or "&lt;word&gt;") and protected-data envelopes are stored unchanged.
/// Unlike <see cref="SanitizeHtmlInString"/> it never substitutes a placeholder for the text.
/// </summary>
public static string SanitizeHtmlForStorage(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.IndexOf('<') < 0)
return value;

try
{
if (!LooksLikeHtml(value))
return value;
return SanitizeHtml(value)?.Trim() ?? string.Empty;
}
catch (Exception ex)
{
Logging.LogException(ex, "Rich text could not be sanitized for storage; storing it with its angle brackets encoded.");
return EncodeAngleBrackets(value);
}
}

/// <summary>
/// True when the text holds markup: a comment, a known HTML element, a namespaced (Word "o:p") or custom ("x-el")
/// element, or any tag carrying attributes. "pressure &lt; 90", "x&lt;y" and "&lt;unconscious&gt;" are plain text.
/// </summary>
public static bool LooksLikeHtml(string text)
{
if (string.IsNullOrEmpty(text) || text.IndexOf('<') < 0)
return false;

foreach (Match match in HtmlTagToken.Matches(text))
{
if (match.Value.StartsWith("<!", StringComparison.Ordinal))
return true;
var name = match.Groups[2].Value;
if (KnownHtmlTags.Contains(name) || name.IndexOf(':') >= 0 || name.IndexOf('-') >= 0)
return true;
if (match.Groups[3].Success && match.Groups[3].Value.IndexOf('=') >= 0)
return true;
}
return false;
}

private static string EncodeAngleBrackets(string text) => text.Replace("<", "&lt;").Replace(">", "&gt;");

private static readonly Regex HtmlTagToken = new Regex(@"<!--|<!doctype|<(/?)([a-zA-Z][\w:.-]*)(\s[^<>]*)?/?>",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

Regex processing without a timeout can allow crafted HTML input to consume excessive CPU and cause a denial-of-service condition. Configure a timeout for HtmlTagToken and the other Regex instances at Core/Resgrid.Framework/StringHelpers.cs:191, 194, and 316, Core/Resgrid.Model/CallNumbering.cs:258, and the listed test files.

Kody rule violation: Specify Timeout for Regular Expressions

Prompt for LLM

File Core/Resgrid.Framework/StringHelpers.cs:

Line 174:

Regex processing without a timeout can allow crafted HTML input to consume excessive CPU and cause a denial-of-service condition. Configure a timeout for HtmlTagToken and the other Regex instances at Core/Resgrid.Framework/StringHelpers.cs:191, 194, and 316, Core/Resgrid.Model/CallNumbering.cs:258, and the listed test files.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));

private static readonly HashSet<string> KnownHtmlTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", "basefont", "bdi", "bdo", "big", "blink",
"blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", "col", "colgroup", "data",
"datalist", "dd", "del", "details", "dfn", "dialog", "dir", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption",
"figure", "font", "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup",
"hr", "html", "i", "iframe", "image", "img", "input", "ins", "isindex", "kbd", "keygen", "label", "legend", "li", "link",
"listing", "main", "map", "mark", "marquee", "math", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed",
"noframes", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "plaintext", "portal",
"pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "search", "section", "select", "slot", "small",
"source", "span", "strike", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template",
"textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "tt", "u", "ul", "var", "video", "wbr", "xml", "xmp"
};

private static readonly Regex HtmlNonTextBlocks = new Regex(@"<!--[\s\S]*?(-->|$)|<(script|style|head|title|xml)\b[^>]*>[\s\S]*?(</\2\s*>|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
// A tag needs a name and a closing '>' with no '<' in between, so "pressure < 90" and "x<y" stay text.
private static readonly Regex HtmlTagPattern = new Regex(@"<(/?)([a-zA-Z][a-zA-Z0-9:-]*)(\s[^<>]*)?/?>|<![^<>]*>",
RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static readonly Regex PlainWhitespace = new Regex(@"\s+", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static readonly HashSet<string> InlineHtmlTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"a", "abbr", "b", "big", "cite", "code", "em", "font", "i", "ins", "del", "kbd", "mark", "q", "s", "samp", "small",
"span", "strike", "strong", "sub", "sup", "tt", "u", "var"
};

/// <summary>
/// The readable text of stored rich text, for previews, excerpts and search: comments, scripts, styles and document
/// heads are dropped with their content, other tags are removed (block tags leave a space so "&lt;div&gt;a&lt;/div&gt;&lt;div&gt;b&lt;/div&gt;"
/// reads "a b"), entities are decoded and whitespace is collapsed. The result is text, not HTML: encode it when it is
/// written into a page. Null or blank in, empty string out.
/// </summary>
public static string HtmlToPlainText(string html)
{
if (string.IsNullOrWhiteSpace(html))
return string.Empty;

var text = html;
try
{
if (text.IndexOf('<') >= 0)
{
text = HtmlNonTextBlocks.Replace(text, " ");
text = HtmlTagPattern.Replace(text, m => m.Groups[2].Success && InlineHtmlTags.Contains(m.Groups[2].Value) ? string.Empty : " ");
}
if (text.IndexOf('&') >= 0)
text = System.Net.WebUtility.HtmlDecode(text);
return PlainWhitespace.Replace(text.Replace(' ', ' '), " ").Trim();
}
catch (RegexMatchTimeoutException)
{
return StripHtmlTagsCharArray(html).Trim();
}
}

/// <summary>The allow-list sanitizer behind <see cref="SanitizeHtmlInString"/> and <see cref="SanitizeHtmlForDisplay"/>;
/// null when the clean-up left nothing.</summary>
private static string SanitizeHtml(string source)
{
StringCollection sc = new StringCollection();
string temp = source;

// get rid of unnecessary tag spans (comments and title)
// get rid of unnecessary tag spans (comments, title, and style blocks whose CSS would otherwise be left behind as text)
sc.Add(@"<!--[\s\S]*?-->");
sc.Add(@"<title>[\s\S]*?</title>");
sc.Add(@"<style\b[^>]*>[\s\S]*?</style\s*>");

// Get rid of unnecessary tags but keep the text they wrap (document wrappers, Word's <o:p> and <st1:place> smart tags)
sc.Add(@"<(meta|link|/?o:|/?style|/?st\d|/?head|/?html|body|/?body)[^>]*?>");
Expand All @@ -100,10 +255,7 @@ public static string SanitizeHtmlInString(string source)
}

if (String.IsNullOrWhiteSpace(source))
{
Logging.LogError("Following string could not be stripped of Word HTML: " + temp);
return "Invalid HTML in Source String";
}
return null;

var sanitizer = new HtmlSanitizer();
sanitizer.Tag("h1").RemoveEmpty();
Expand Down Expand Up @@ -149,15 +301,22 @@ public static string SanitizeHtmlInString(string source)
.SanitizeAttributes("href", new SpaceEncodingUrlAttributeSanitizer())
.RemoveEmpty();

string cleanHtml = sanitizer.Sanitize(source);

if (!String.IsNullOrWhiteSpace(cleanHtml))
return cleanHtml.Trim();

//Logging.LogError("Following string could not be sanitized: " + source);
return "Invalid HTML in Source String";
// The sanitizer writes every non-ASCII character and the apostrophe as an entity ("l'école" becomes "l&#39;&#233;cole").
// Those characters can never form markup (attributes are written in double quotes), and exports, voice prompts and
// texts that strip tags without decoding entities printed or read the entity, so they are put back as characters.
// &amp; &lt; &gt; &quot; and &nbsp; stay encoded.
return NonAsciiEntity.Replace(sanitizer.Sanitize(source) ?? string.Empty, m =>
{
var decoded = System.Net.WebUtility.HtmlDecode(m.Value);
if (decoded.Length == 0 || decoded == m.Value)
return m.Value;
return decoded[0] > ' ' || decoded == "'" ? decoded : m.Value;
});
}

private static readonly Regex NonAsciiEntity = new Regex(@"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});",
RegexOptions.Compiled, TimeSpan.FromSeconds(1));

/// <summary>
/// Tags <see cref="SanitizeHtmlInString"/> unwraps (tag removed, text kept) instead of deleting with their content.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>انتهت صلاحية العرض</value></data>
<data name="BidLine" xml:space="preserve"><value>بند العرض</value></data>
<data name="BidNumber" xml:space="preserve"><value>العرض رقم</value></data>
<data name="Filter" xml:space="preserve"><value>تصفية</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>تأخذ البنود سعرها من هذا الجدول عند الإضافة.</value></data>
<data name="BidSent" xml:space="preserve"><value>تم إرسال العرض</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>مقبول</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Angebot abgelaufen</value></data>
<data name="BidLine" xml:space="preserve"><value>Angebotsposition</value></data>
<data name="BidNumber" xml:space="preserve"><value>Angebot Nr.</value></data>
<data name="Filter" xml:space="preserve"><value>Filtern</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Positionen übernehmen ihren Satz beim Hinzufügen aus diesem Tarifplan.</value></data>
<data name="BidSent" xml:space="preserve"><value>Angebot gesendet</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Angenommen</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Έληξε προσφορά</value></data>
<data name="BidLine" xml:space="preserve"><value>Γραμμή προσφοράς</value></data>
<data name="BidNumber" xml:space="preserve"><value>Προσφορά αρ.</value></data>
<data name="Filter" xml:space="preserve"><value>Φίλτρο</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Οι γραμμές παίρνουν την τιμή τους από αυτόν τον τιμοκατάλογο κατά την προσθήκη.</value></data>
<data name="BidSent" xml:space="preserve"><value>Απεστάλη προσφορά</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Αποδεκτή</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Bid expired</value></data>
<data name="BidLine" xml:space="preserve"><value>Bid line</value></data>
<data name="BidNumber" xml:space="preserve"><value>Bid #</value></data>
<data name="Filter" xml:space="preserve"><value>Filter</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Lines snapshot their rate from this schedule when added.</value></data>
<data name="BidSent" xml:space="preserve"><value>Bid sent</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Accepted</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Oferta vencida</value></data>
<data name="BidLine" xml:space="preserve"><value>Línea de oferta</value></data>
<data name="BidNumber" xml:space="preserve"><value>Oferta n.º</value></data>
<data name="Filter" xml:space="preserve"><value>Filtrar</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Las líneas toman su tarifa de esta tabla al añadirse.</value></data>
<data name="BidSent" xml:space="preserve"><value>Oferta enviada</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Aceptada</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Offre expirée</value></data>
<data name="BidLine" xml:space="preserve"><value>Ligne d'offre</value></data>
<data name="BidNumber" xml:space="preserve"><value>Offre n°</value></data>
<data name="Filter" xml:space="preserve"><value>Filtrer</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Les lignes figent leur taux depuis cette grille à l'ajout.</value></data>
<data name="BidSent" xml:space="preserve"><value>Offre envoyée</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Acceptée</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Offerta scaduta</value></data>
<data name="BidLine" xml:space="preserve"><value>Riga offerta</value></data>
<data name="BidNumber" xml:space="preserve"><value>Offerta n.</value></data>
<data name="Filter" xml:space="preserve"><value>Filtra</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Le righe copiano la tariffa da questo listino quando aggiunte.</value></data>
<data name="BidSent" xml:space="preserve"><value>Offerta inviata</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Accettata</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Oferta wygasła</value></data>
<data name="BidLine" xml:space="preserve"><value>Pozycja oferty</value></data>
<data name="BidNumber" xml:space="preserve"><value>Oferta nr</value></data>
<data name="Filter" xml:space="preserve"><value>Filtruj</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Pozycje pobierają stawkę z tego cennika przy dodaniu.</value></data>
<data name="BidSent" xml:space="preserve"><value>Oferta wysłana</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Przyjęta</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Bid expired</value></data>
<data name="BidLine" xml:space="preserve"><value>Bid line</value></data>
<data name="BidNumber" xml:space="preserve"><value>Bid #</value></data>
<data name="Filter" xml:space="preserve"><value>Filter</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Lines snapshot their rate from this schedule when added.</value></data>
<data name="BidSent" xml:space="preserve"><value>Bid sent</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Accepted</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Anbud utgånget</value></data>
<data name="BidLine" xml:space="preserve"><value>Anbudsrad</value></data>
<data name="BidNumber" xml:space="preserve"><value>Anbud nr</value></data>
<data name="Filter" xml:space="preserve"><value>Filtrera</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Rader låser sin taxa från denna prislista när de läggs till.</value></data>
<data name="BidSent" xml:space="preserve"><value>Anbud skickat</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Antaget</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<data name="BidExpired" xml:space="preserve"><value>Пропозицію прострочено</value></data>
<data name="BidLine" xml:space="preserve"><value>Рядок пропозиції</value></data>
<data name="BidNumber" xml:space="preserve"><value>Пропозиція №</value></data>
<data name="Filter" xml:space="preserve"><value>Фільтр</value></data>
<data name="BidScheduleHelp" xml:space="preserve"><value>Рядки фіксують ставку з цих тарифів під час додавання.</value></data>
<data name="BidSent" xml:space="preserve"><value>Пропозицію надіслано</value></data>
<data name="BidStatusAccepted" xml:space="preserve"><value>Прийнято</value></data>
Expand Down
25 changes: 14 additions & 11 deletions Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
Original file line number Diff line number Diff line change
Expand Up @@ -513,18 +513,9 @@
<data name="NearestUnitsPick" xml:space="preserve">
<value>إرسال</value>
</data>
<data name="NearestPersonnelLabel" xml:space="preserve">
<value>أقرب الأفراد</value>
</data>
<data name="NearestPersonnelPerson" xml:space="preserve">
<value>الشخص</value>
</data>
<data name="NearestPersonnelStaffing" xml:space="preserve">
<value>الجاهزية</value>
</data>
<data name="NearestPersonnelGroupRoles" xml:space="preserve">
<value>المجموعة / الأدوار</value>
</data>
<data name="NearestUnitsNearest" xml:space="preserve">
<value>الأقرب رقم {0}</value>
</data>
Expand All @@ -543,7 +534,19 @@
<data name="NearestUnitsMapBoundaries" xml:space="preserve">
<value>حدود المحطات</value>
</data>
<data name="NearestUnitsShowingClosest" xml:space="preserve">
<value>يتم عرض أقرب {0} من أصل {1}. تتضمن قوائم الإرسال أدناه الجميع، الأقرب أولًا.</value>
<data name="UngroupedUnitsTab" xml:space="preserve">
<value>وحدات غير مجمعة</value>
</data>
<data name="NearestUnitsLoadFailed" xml:space="preserve">
<value>تعذر تحميل الوحدات. أعد تحميل الصفحة للمحاولة مرة أخرى.</value>
</data>
<data name="NearestUnitsShowOnMap" xml:space="preserve">
<value>عرض هذا الموقع وموقع البلاغ على الخريطة</value>
</data>
<data name="NearestUnitsPositionTitle" xml:space="preserve">
<value>{0}: الموقع</value>
</data>
<data name="NearestUnitsCallLocation" xml:space="preserve">
<value>موقع البلاغ</value>
</data>
</root>
Loading
Loading