-
-
Notifications
You must be signed in to change notification settings - Fork 0
Drop control characters from attribute values, and check attribute names #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,6 +102,26 @@ internal static void WriteWrapped( | |
| /// only navigate, so a command link — and any URL with a scheme | ||
| /// <see cref="UrlSafety.IsSafeNavigableUrl"/> rejects — is written as plain text. | ||
| /// </summary> | ||
| /// <summary> | ||
| /// A link's URL, hint or text as it is written into an attribute or an escape sequence: HTML-encoded, | ||
| /// with control characters dropped. A newline would end the line the tag is on — under MXP's | ||
| /// secure-line framing, the rest of the tag then reaches the player as text — and a BEL or ESC would | ||
| /// end an OSC 8 sequence early and put the remainder on the terminal as escapes. | ||
| /// </summary> | ||
| internal static string EncodeAttribute(string value) | ||
| { | ||
| var encoded = WebUtility.HtmlEncode(value); | ||
| return encoded.AsSpan().ContainsAnyInRange('\u0000', '\u001f') || encoded.Contains('\u007f') | ||
| ? new string([.. encoded.Where(c => !char.IsControl(c))]) | ||
| : encoded; | ||
| } | ||
|
|
||
| /// <summary>An OSC 8 target, which is not HTML: only the control characters that would end the sequence go.</summary> | ||
| internal static string EncodeOsc8(string url) => | ||
| url.AsSpan().ContainsAnyInRange('\u0000', '\u001f') || url.Contains('\u007f') | ||
| ? new string([.. url.Where(c => !char.IsControl(c))]) | ||
| : url; | ||
|
|
||
| internal static void WriteHyperlinked(in AnsiStyle style, ReadOnlySpan<char> body, IBufferWriter<char> output) | ||
| { | ||
| if (style.LinkKind != LinkKind.Url | ||
|
|
@@ -113,7 +133,7 @@ internal static void WriteHyperlinked(in AnsiStyle style, ReadOnlySpan<char> bod | |
| } | ||
|
|
||
| output.Write(Osc8); | ||
| output.Write(url); | ||
| output.Write(EncodeOsc8(url)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- AnsiEmitterSupport outline ---'
ast-grep outline MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs
printf '%s\n' '--- cited support source ---'
sed -n '95,235p' MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs
printf '%s\n' '--- HTML emitter outline and cited source ---'
ast-grep outline MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
sed -n '95,155p' MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
printf '%s\n' '--- relevant declarations and usages ---'
rg -n -C 4 'IsSafeNavigableUrl|EncodeOsc8|EncodeAttribute|control|Control' MarkupString.Ansi
printf '%s\n' '--- relevant tests ---'
rg -n -C 3 'java|script|UrlSafety|navigable|href|OSC|Pueblo|MXP' --glob '*Test*' --glob '*.cs' .Repository: SharpMUSH/MarkupString Length of output: 50378 🏁 Script executed: set -e
sed -n '1,260p' MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs
sed -n '1,180p' MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
rg -n -C 5 'IsSafeNavigableUrl|EncodeOsc8|EncodeAttribute|Remove|Control' MarkupString.AnsiRepository: SharpMUSH/MarkupString Length of output: 27413 🏁 Script executed: set -e
cat -n MarkupString.Ansi/UrlSafety.cs
printf '%s\n' '--- URL safety tests ---'
rg -n -C 8 'IsSafeNavigableUrl|javascript:|vbscript:|data:|file:|control|whitespace' MarkupString.Tests MarkupString.Ansi --glob '*.cs'Repository: SharpMUSH/MarkupString Length of output: 25887 🏁 Script executed: set -e
cat -n MarkupString.Ansi/UrlSafety.cs
rg -n -C 8 'IsSafeNavigableUrl|javascript:|vbscript:|data:|file:|control|whitespace' MarkupString.Tests MarkupString.Ansi --glob '*.cs'Repository: SharpMUSH/MarkupString Length of output: 25862 🏁 Script executed: set +e
for tool in dotnet csi csharp mcs mono; do
command -v "$tool" && "$tool" --version 2>&1 | head -n 1
doneRepository: SharpMUSH/MarkupString Length of output: 160 🏁 Script executed: set +e
for tool in dotnet csi csharp mcs mono; do
command -v "$tool" && "$tool" --version 2>&1 | head -n 1
doneRepository: SharpMUSH/MarkupString Length of output: 160 🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations:
Normalize URLs before validating navigable links. The URL branches validate the raw Normalize the URL once before calling 🧰 Tools🪛 OpenGrep (1.29.0)[WARNING] 136-136: Response.Write() with dynamic content can lead to XSS. Use HTML encoding or Razor syntax with automatic escaping instead. (coderabbit.xss.csharp-response-write) 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Learnings |
||
| output.Write(Bel); | ||
| output.Write(body); | ||
| output.Write(Osc8); | ||
|
|
@@ -171,12 +191,12 @@ private static void WriteTaggedLink( | |
| : ("<A XCH_CMD=\"", " XCH_HINT=\"", "</A>"); | ||
|
|
||
| output.Write(open); | ||
| output.Write(WebUtility.HtmlEncode(url)); | ||
| output.Write(EncodeAttribute(url)); | ||
| output.Write("\""); | ||
| if (style.LinkText is { Length: > 0 } text) | ||
| { | ||
| output.Write(hint); | ||
| output.Write(WebUtility.HtmlEncode(text)); | ||
| output.Write(EncodeAttribute(text)); | ||
| output.Write("\""); | ||
| } | ||
| output.Write(">"); | ||
|
|
@@ -192,7 +212,7 @@ private static void WriteTaggedLink( | |
| } | ||
|
|
||
| output.Write("<A HREF=\""); | ||
| output.Write(WebUtility.HtmlEncode(url)); | ||
| output.Write(EncodeAttribute(url)); | ||
| output.Write("\">"); | ||
| output.Write(body); | ||
| output.Write("</A>"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,24 +1,75 @@ | ||||||||||
| using System.Text; | ||||||||||
| namespace MarkupString.Html; | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// One attribute of an <see cref="HtmlMarkup"/> tag: a name and its unencoded value. It is written | ||||||||||
| /// as <c>name="value"</c> with the value encoded, so a quote or a <c>></c> in it cannot end the | ||||||||||
| /// attribute or the tag. | ||||||||||
| /// as <c>name="value"</c> with the value encoded, so nothing in it can end the attribute, the tag | ||||||||||
| /// or the line. | ||||||||||
| /// </summary> | ||||||||||
| /// <param name="Name">The attribute name, checked by <see cref="HtmlMarkup.IsValidAttributeName"/> wherever it is written.</param> | ||||||||||
| /// <param name="Name">The attribute name, which must pass <see cref="HtmlMarkup.IsValidAttributeName"/>.</param> | ||||||||||
| /// <param name="Value">The value as it should read after decoding; the empty string for a bare attribute.</param> | ||||||||||
| /// <exception cref="ArgumentException"><paramref name="Name"/> is not an attribute name.</exception> | ||||||||||
| public readonly record struct HtmlAttribute(string Name, string Value) | ||||||||||
| { | ||||||||||
| /// <summary>The four characters that are markup inside a double-quoted attribute value.</summary> | ||||||||||
| private const string Significant = "&\"<>"; | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// Whether <paramref name="value"/> holds anything this has to rewrite: one of | ||||||||||
| /// <see cref="Significant"/>, or a control character. A newline in a value would end the line the tag | ||||||||||
| /// sits on — in MXP, a line in secure mode whose successor is not — and an ESC would open an escape | ||||||||||
| /// sequence inside the tag. <see cref="MarkupTextRenderer"/> drops controls from body text; a value | ||||||||||
| /// is no different, and a value read out of somewhere untrusted is where they arrive. | ||||||||||
| /// </summary> | ||||||||||
| private static bool NeedsEncoding(ReadOnlySpan<char> value) => | ||||||||||
| value.IndexOfAny(Significant) >= 0 | ||||||||||
| || value.ContainsAnyInRange('\u0000', '\u001f') | ||||||||||
| || value.Contains('\u007f'); | ||||||||||
|
Comment on lines
+26
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Remove C1 control characters before the fast-path return.
Proposed fix value.IndexOfAny(Significant) >= 0
|| value.ContainsAnyInRange('\u0000', '\u001f')
- || value.Contains('\u007f');
+ || value.ContainsAnyInRange('\u007f', '\u009f');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| /// <summary>The attribute name, which must pass <see cref="HtmlMarkup.IsValidAttributeName"/>.</summary> | ||||||||||
| /// <remarks> | ||||||||||
| /// Checked here rather than only in <see cref="HtmlMarkup.Tag"/>: <see cref="ToString"/> is public, and | ||||||||||
| /// a name holding a space or a quote would write a second attribute out of one. | ||||||||||
| /// </remarks> | ||||||||||
| public string Name { get; init => field = Validated(value); } = Validated(Name); | ||||||||||
|
|
||||||||||
| /// <exception cref="ArgumentException"><paramref name="name"/> is not an attribute name.</exception> | ||||||||||
| private static string Validated(string? name) => | ||||||||||
| name is null || HtmlMarkup.IsValidAttributeName(name) | ||||||||||
| ? name! | ||||||||||
|
Comment on lines
+38
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,120p' MarkupString.Html/HtmlAttribute.cs
rg -n "HtmlAttribute|Validated\\(|ToString\\(" MarkupString.Html MarkupString.Tests | head -240Repository: SharpMUSH/MarkupString Length of output: 11100 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- HtmlMarkup validation and construction ---'
rg -n -A24 -B8 "IsValidAttributeName|public static HtmlMarkup Tag|TryParseAttributes" MarkupString.Html/HtmlMarkup.cs
printf '%s\n' '--- project language/nullability settings ---'
rg -n -A8 -B4 "Nullable|LangVersion|TargetFramework" --glob '*.csproj' --glob 'Directory.*' --glob '*.props' --glob '*.targets' .
printf '%s\n' '--- focused tests and documentation ---'
sed -n '1,115p' MarkupString.Tests/Html/HtmlTagPolicyTests.cs
sed -n '1,95p' MarkupString.Html/README.md
printf '%s\n' '--- public API contract excerpt ---'
sed -n '1,45p' MarkupString.Html/PublicAPI.Unshipped.txtRepository: SharpMUSH/MarkupString Length of output: 17352 Reject null attribute names.
Proposed fix- name is null || HtmlMarkup.IsValidAttributeName(name)
- ? name!
+ name is not null && HtmlMarkup.IsValidAttributeName(name)
+ ? name📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| : throw new ArgumentException($"'{name}' is not an attribute name.", nameof(name)); | ||||||||||
|
|
||||||||||
| /// <summary>The attribute as written in a tag: <c>name="value"</c>, the value encoded.</summary> | ||||||||||
| public override string ToString() => $"{Name}=\"{Encode(Value)}\""; | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// Encodes the four characters that matter inside a double-quoted attribute value: <c>&</c>, | ||||||||||
| /// <c>"</c>, <c><</c> and <c>></c>. Nothing else is touched — a numeric entity for an | ||||||||||
| /// accented letter is one more thing an MXP or Pueblo client may not decode. | ||||||||||
| /// Encodes the four characters that are markup inside a double-quoted attribute value — | ||||||||||
| /// <c>&</c>, <c>"</c>, <c><</c> and <c>></c> — and drops control characters. Nothing else | ||||||||||
| /// is touched: a numeric entity for an accented letter is one more thing an MXP or Pueblo client may | ||||||||||
| /// not decode. | ||||||||||
| /// </summary> | ||||||||||
| internal static string Encode(string value) => | ||||||||||
| value.AsSpan().IndexOfAny("&\"<>") < 0 | ||||||||||
| ? value | ||||||||||
| : value.Replace("&", "&").Replace("\"", """).Replace("<", "<").Replace(">", ">"); | ||||||||||
| internal static string Encode(string? value) | ||||||||||
| { | ||||||||||
| if (string.IsNullOrEmpty(value)) return string.Empty; | ||||||||||
|
|
||||||||||
| var text = value.AsSpan(); | ||||||||||
| if (!NeedsEncoding(text)) return value; | ||||||||||
|
|
||||||||||
| var written = new StringBuilder(value.Length + 16); | ||||||||||
| foreach (var c in text) | ||||||||||
| { | ||||||||||
| switch (c) | ||||||||||
| { | ||||||||||
| case '&': written.Append("&"); break; | ||||||||||
| case '"': written.Append("""); break; | ||||||||||
| case '<': written.Append("<"); break; | ||||||||||
| case '>': written.Append(">"); break; | ||||||||||
| default: | ||||||||||
| if (!char.IsControl(c)) written.Append(c); | ||||||||||
| break; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return written.ToString(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Remove C1 control characters.
The fast paths only detect C0 controls and DEL. Values containing only U+0080 through U+009F return unchanged, although the filtering predicate uses
char.IsControl. UseAny(char.IsControl)or include the C1 range in both checks.Also applies to: 121-121
🤖 Prompt for AI Agents