From b2bb43ab1fbc192df18458210f32c4d96c4fd0a7 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sat, 19 Sep 2026 15:55:51 -0500 Subject: [PATCH 1/4] Checked HtmlMarkup, tag policies, line framers and MXP secure lines Consumers were each re-implementing what the library should own: - HtmlMarkup took a raw tag name and attribute string and wrote both unchecked. HtmlMarkup.Tag builds one from a checked name and encoded HtmlAttributes; TryParseAttributes reads a raw string with values decoded as a client reads them; HtmlTagPolicy (BrowserSafe, WellFormed, or your own via `with`) keeps only the tags and attributes it allows, with addresses checked against UrlSafety and refused if they hide whitespace. - WithHtml(policy) / HtmlTagEmitter(format, policy) holds every tag rendered in Html to the policy as it is written, so deserialised or unchecked markup cannot reach a browser unfiltered. Pueblo and MXP are unchanged. - MXP output needs ESC[1z on every line or the client prints the tags; each consumer was prefixing lines itself. ILineFramer is a per-line framer slot in the registry, and MxpSecureLineFramer / WithMxpSecureLines() supplies MXP's, opt-in for the registry that renders for a connection. The guides presented HtmlMarkup.Create("send", ...) as a portable link. is MXP's command link and a Pueblo client prints it; they now build links with LinkKind.Command, and the formats guide documents how Pueblo and MXP differ. It also no longer claims TextEncoding.Html escapes quotes. All additive; package validation against 2.1.0 passes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++++ MarkupString.Ansi/AnsiRegistration.cs | 12 ++ MarkupString.Ansi/MxpSecureLineFramer.cs | 36 ++++ MarkupString.Ansi/PublicAPI.Unshipped.txt | 6 + MarkupString.Ansi/README.md | 9 + MarkupString.Html/Emitters/HtmlTagEmitter.cs | 36 +++- MarkupString.Html/HtmlAttribute.cs | 24 +++ MarkupString.Html/HtmlMarkup.cs | 128 +++++++++++++- MarkupString.Html/HtmlRegistration.cs | 13 ++ MarkupString.Html/HtmlTagPolicy.cs | 120 +++++++++++++ MarkupString.Html/MarkupString.Html.csproj | 2 +- MarkupString.Html/PublicAPI.Unshipped.txt | 46 +++++ MarkupString.Html/README.md | 30 +++- .../Ansi/MxpSecureLineFramerTests.cs | 52 ++++++ MarkupString.Tests/Html/HtmlTagPolicyTests.cs | 165 ++++++++++++++++++ MarkupString/ILineFramer.cs | 21 +++ MarkupString/MarkupRegistry.cs | 26 ++- MarkupString/MarkupTextRenderer.cs | 29 +++ MarkupString/PublicAPI.Unshipped.txt | 5 + README.md | 8 +- docs/formats.md | 68 ++++++-- docs/getting-started.md | 9 +- 22 files changed, 841 insertions(+), 35 deletions(-) create mode 100644 MarkupString.Ansi/MxpSecureLineFramer.cs create mode 100644 MarkupString.Html/HtmlAttribute.cs create mode 100644 MarkupString.Html/HtmlTagPolicy.cs create mode 100644 MarkupString.Tests/Ansi/MxpSecureLineFramerTests.cs create mode 100644 MarkupString.Tests/Html/HtmlTagPolicyTests.cs create mode 100644 MarkupString/ILineFramer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 431655e..9bd2d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- **Checked `HtmlMarkup` construction.** `HtmlMarkup.Tag(name, params attributes)` validates the + tag and attribute names and writes each value encoded, so nothing in a value can end the attribute + or the tag. `HtmlMarkup.IsValidTagName`, `IsValidAttributeName` and `TryParseAttributes` (which + reads a raw attribute string with its values decoded, as a client reads them) are public. +- **`HtmlTagPolicy`**, for tags from somewhere untrusted: which tags and attributes are allowed, + which attributes carry an address (checked with `UrlSafety`, and refused if they hold whitespace + or a control character a browser would strip), and whether one bad attribute drops just itself or + all of them. `TryCreate` builds a checked tag from a name and a raw attribute string; `Apply` holds + an existing one to the policy. `BrowserSafe` and `WellFormed` are provided, and a policy is a + record, so `with` narrows one. +- **`WithHtml(HtmlTagPolicy)`** and `HtmlTagEmitter(format, policy)`: every tag rendered in `Html` is + held to the policy as it is written, including markup that arrived deserialised or was built with + the unchecked `HtmlMarkup.Create`. A refused tag leaves its body in place. Pueblo and MXP output is + unchanged. +- **`ILineFramer`**, registered with `MarkupRegistry.With(ILineFramer)` and found with + `FindLineFramer`: a prefix written at the start of every line that has content, in a slot of its + own beside `IFormatFramer`. +- **`MxpSecureLineFramer`** and `WithMxpSecureLines()` in `MarkupString.Ansi`: open every line of + `Mxp` output in secure mode (`ESC[1z`), which an MXP client needs before it reads the tags on a + line. Opt-in, for the registry that renders for a connection. + +### Documentation + +- The guides no longer show `HtmlMarkup.Create("send", ...)` as a portable link. `` is MXP's + command link; a Pueblo client prints it as text. The formats guide now has a Pueblo-and-MXP table, + and every example builds a link with `AnsiMarkup`'s `LinkKind.Command`, which each format writes + in its own dialect. +- The formats guide said `TextEncoding.Html` escapes `"`; it has not since 2.1.0. + ## 2.1.0 — 2026-09-08 ### Added diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs index e63a317..adc689b 100644 --- a/MarkupString.Ansi/AnsiRegistration.cs +++ b/MarkupString.Ansi/AnsiRegistration.cs @@ -25,4 +25,16 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry) .With(new AnsiBBCodeEmitter()) .With(new AnsiMarkupCodec()); } + + /// + /// Returns a registry that opens every line of output in MXP secure + /// mode, which an MXP client needs before it reads the tags on that line. Apply it to the registry + /// that renders for an MXP connection, not to — see + /// . + /// + public static MarkupRegistry WithMxpSecureLines(this MarkupRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.With(MxpSecureLineFramer.Instance); + } } diff --git a/MarkupString.Ansi/MxpSecureLineFramer.cs b/MarkupString.Ansi/MxpSecureLineFramer.cs new file mode 100644 index 0000000..d3a89a8 --- /dev/null +++ b/MarkupString.Ansi/MxpSecureLineFramer.cs @@ -0,0 +1,36 @@ +using System.Buffers; +namespace MarkupString.Ansi; + +/// +/// Opens every line of output in MXP secure mode (ESC[1z). An MXP +/// client reads tags only on a line in secure mode, and the mode ends at the newline, so without this +/// on each line the <SEND> and <A> this package writes reach the player as +/// text. Install it with . +/// +/// +/// Not part of : the prefix belongs to output bound for an MXP +/// session, and a render of the same text for a test, a log or a preview wants the tags alone. Use a +/// registry with this framer at the connection boundary and the plain one everywhere else. +/// +public sealed class MxpSecureLineFramer : ILineFramer +{ + /// The secure-line mode sequence, ESC[1z. + public const string SecureLine = "\e[1z"; + + /// The one instance; the framer carries no state. + public static readonly MxpSecureLineFramer Instance = new(); + + private MxpSecureLineFramer() + { + } + + /// + public MarkupFormat Format => MarkupFormat.Mxp; + + /// + public void WriteLineStart(IBufferWriter output) + { + ArgumentNullException.ThrowIfNull(output); + output.Write(SecureLine); + } +} diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 7dc5c58..5d72c03 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +const MarkupString.Ansi.MxpSecureLineFramer.SecureLine = "\u001b[1z" -> string! +MarkupString.Ansi.MxpSecureLineFramer +MarkupString.Ansi.MxpSecureLineFramer.Format.get -> MarkupString.MarkupFormat! +MarkupString.Ansi.MxpSecureLineFramer.WriteLineStart(System.Buffers.IBufferWriter! output) -> void +static MarkupString.Ansi.AnsiRegistration.WithMxpSecureLines(this MarkupString.MarkupRegistry! registry) -> MarkupString.MarkupRegistry! +static readonly MarkupString.Ansi.MxpSecureLineFramer.Instance -> MarkupString.Ansi.MxpSecureLineFramer! diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index 1ff8da2..15956b5 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -40,6 +40,15 @@ var back = MarkupTextSerializer.Deserialize(json); `WithAnsi()` registers emitters for `Ansi`, `Html`, `Pueblo`, `Mxp` and `BBCode`. `Plain` needs none: the body passes through. +A command link (`linkKind: LinkKind.Command`) is written in each client's own dialect: `` +for Pueblo, `` for MXP, an `ms-cmd-link` anchor carrying `xch_cmd` for HTML, and the bare +text for a terminal. Pueblo and MXP are different dialects, and each client prints the other's +tags as text. + +An MXP client reads tags only on a line opened in secure mode. `WithMxpSecureLines()` adds +`MxpSecureLineFramer`, which opens every line of `Mxp` output with `ESC[1z`. Use it on the registry +that renders for an MXP connection, and leave it off the one used for tests, logs and previews. + ## Styling the HTML output The HTML-family emitters write `ms-*` classes for the attributes with a fixed rendering (bold, diff --git a/MarkupString.Html/Emitters/HtmlTagEmitter.cs b/MarkupString.Html/Emitters/HtmlTagEmitter.cs index c4513e3..e80f7ee 100644 --- a/MarkupString.Html/Emitters/HtmlTagEmitter.cs +++ b/MarkupString.Html/Emitters/HtmlTagEmitter.cs @@ -9,14 +9,34 @@ namespace MarkupString.Html; /// want the tag written verbatim, unlike Ansi/BBCode, where folds a /// handful of tags into terminal styling instead (see ). /// -/// Neither the tag name, the attributes, nor the body are encoded — the tag is written raw. -public sealed class HtmlTagEmitter(MarkupFormat format) : IMarkupEmitter +/// +/// Without a policy the tag name and attributes are written exactly as the markup carries them. With +/// one, every tag is held to it as it is written — see — and a tag it +/// refuses leaves its body in place, unwrapped. The body is encoded by the renderer either way. +/// +public sealed class HtmlTagEmitter : IMarkupEmitter { + /// An emitter for that writes every tag as the markup carries it. + public HtmlTagEmitter(MarkupFormat format) : this(format, null) + { + } + + /// An emitter for that holds every tag to , when one is given. + public HtmlTagEmitter(MarkupFormat format, HtmlTagPolicy? policy) + { + ArgumentNullException.ThrowIfNull(format); + Format = format; + Policy = policy; + } + /// public Type MarkupType => typeof(HtmlMarkup); /// - public MarkupFormat Format { get; } = format; + public MarkupFormat Format { get; } + + /// The policy every tag is held to, or to write tags as given. + public HtmlTagPolicy? Policy { get; } /// public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) @@ -25,6 +45,16 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context ArgumentNullException.ThrowIfNull(output); var html = (HtmlMarkup)markup; + if (Policy is not null) + { + if (Policy.Apply(html) is not { } held) + { + output.Write(body); + return; + } + + html = held; + } output.Write("<"); output.Write(html.TagName); diff --git a/MarkupString.Html/HtmlAttribute.cs b/MarkupString.Html/HtmlAttribute.cs new file mode 100644 index 0000000..e7c9e53 --- /dev/null +++ b/MarkupString.Html/HtmlAttribute.cs @@ -0,0 +1,24 @@ +namespace MarkupString.Html; + +/// +/// One attribute of an tag: a name and its unencoded value. It is written +/// as name="value" with the value encoded, so a quote or a > in it cannot end the +/// attribute or the tag. +/// +/// The attribute name, checked by wherever it is written. +/// The value as it should read after decoding; the empty string for a bare attribute. +public readonly record struct HtmlAttribute(string Name, string Value) +{ + /// The attribute as written in a tag: name="value", the value encoded. + public override string ToString() => $"{Name}=\"{Encode(Value)}\""; + + /// + /// Encodes the four characters that matter inside a double-quoted attribute value: &, + /// ", < and >. Nothing else is touched — a numeric entity for an + /// accented letter is one more thing an MXP or Pueblo client may not decode. + /// + internal static string Encode(string value) => + value.AsSpan().IndexOfAny("&\"<>") < 0 + ? value + : value.Replace("&", "&").Replace("\"", """).Replace("<", "<").Replace(">", ">"); +} diff --git a/MarkupString.Html/HtmlMarkup.cs b/MarkupString.Html/HtmlMarkup.cs index d131410..da8258a 100644 --- a/MarkupString.Html/HtmlMarkup.cs +++ b/MarkupString.Html/HtmlMarkup.cs @@ -1,3 +1,4 @@ +using System.Net; using MarkupString.Ansi; namespace MarkupString.Html; @@ -7,14 +8,135 @@ namespace MarkupString.Html; /// tagged spans coalesce. /// /// -/// Neither nor is sanitised or encoded — callers are -/// expected to have already validated them, the same way the format this replaces did. +/// takes the tag name and attribute string as given and writes them unchecked. +/// builds one from a checked name and s it encodes, and +/// does the same for a raw attribute string from somewhere +/// untrusted, keeping only what the policy allows. /// public sealed record HtmlMarkup(string TagName, string? Attributes) : IMarkup, IAnsiStyleSource { - /// Creates a layer for , optionally with a raw attribute string. + /// Creates a layer for , optionally with a raw attribute string, both unchecked. public static HtmlMarkup Create(string tagName, string? attributes = null) => new(tagName, attributes); + /// + /// Creates a layer for with , each written + /// name="value" with its value encoded. + /// + /// + /// fails , or an attribute's name fails + /// . + /// + public static HtmlMarkup Tag(string tagName, params ReadOnlySpan attributes) + { + ArgumentNullException.ThrowIfNull(tagName); + if (!IsValidTagName(tagName)) + { + throw new ArgumentException($"'{tagName}' is not a tag name: a letter, then letters, digits or hyphens.", nameof(tagName)); + } + + foreach (var attribute in attributes) + { + if (attribute.Name is null || !IsValidAttributeName(attribute.Name)) + { + throw new ArgumentException($"'{attribute.Name}' is not an attribute name.", nameof(attributes)); + } + } + + return new HtmlMarkup(tagName, Join(attributes)); + } + + /// + /// Whether can stand as a tag name: an ASCII letter, then ASCII letters, + /// digits or hyphens. Anything else — a space, a quote, a > — would let the name carry + /// attributes of its own or close the tag. + /// + public static bool IsValidTagName(ReadOnlySpan name) + { + if (name.IsEmpty || !char.IsAsciiLetter(name[0])) return false; + foreach (var c in name[1..]) + { + if (!char.IsAsciiLetterOrDigit(c) && c != '-') return false; + } + + return true; + } + + /// + /// Whether can stand as an attribute name: an ASCII letter, _ or + /// :, then ASCII letters, digits, _, :, . or -. + /// + public static bool IsValidAttributeName(ReadOnlySpan name) + { + if (name.IsEmpty || !(char.IsAsciiLetter(name[0]) || name[0] is '_' or ':')) return false; + foreach (var c in name[1..]) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not ('_' or ':' or '.' or '-')) return false; + } + + return true; + } + + /// + /// Reads a raw attribute string — href="x" title='y' size=3 noshade — into its attributes, + /// with each value as a client reads it: out of its quotes, and with its entities decoded, so + /// &#106;avascript: is checked as the javascript: it is. False when the string is + /// malformed: a name that fails , or a quote that is never closed. + /// + public static bool TryParseAttributes(string? attributes, out IReadOnlyList parsed) + { + List found = []; + parsed = found; + var rest = (attributes ?? string.Empty).AsSpan().Trim(); + while (!rest.IsEmpty) + { + var nameEnd = rest.IndexOfAny("= \t\r\n"); + var name = nameEnd < 0 ? rest : rest[..nameEnd]; + if (!IsValidAttributeName(name)) return false; + + rest = rest[name.Length..].TrimStart(); + if (rest.IsEmpty || rest[0] != '=') + { + found.Add(new HtmlAttribute(name.ToString(), string.Empty)); + continue; + } + + rest = rest[1..].TrimStart(); + if (!TakeValue(ref rest, out var value)) return false; + found.Add(new HtmlAttribute(name.ToString(), value)); + rest = rest.TrimStart(); + } + + return true; + } + + private static bool TakeValue(ref ReadOnlySpan rest, out string value) + { + value = string.Empty; + if (rest.IsEmpty) return true; + + if (rest[0] is '"' or '\'') + { + var close = rest[1..].IndexOf(rest[0]); + if (close < 0) return false; + value = WebUtility.HtmlDecode(rest.Slice(1, close).ToString()); + rest = rest[(close + 2)..]; + return true; + } + + var end = rest.IndexOfAny(" \t\r\n"); + value = WebUtility.HtmlDecode((end < 0 ? rest : rest[..end]).ToString()); + rest = end < 0 ? [] : rest[end..]; + return true; + } + + internal static string? Join(ReadOnlySpan attributes) + { + if (attributes.IsEmpty) return null; + List written = new(attributes.Length); + foreach (var attribute in attributes) written.Add(attribute.ToString()); + return string.Join(' ', written); + } + /// /// /// Only and have a terminal diff --git a/MarkupString.Html/HtmlRegistration.cs b/MarkupString.Html/HtmlRegistration.cs index a14733c..9ee2e82 100644 --- a/MarkupString.Html/HtmlRegistration.cs +++ b/MarkupString.Html/HtmlRegistration.cs @@ -21,4 +21,17 @@ public static MarkupRegistry WithHtml(this MarkupRegistry registry) .With(new HtmlTagEmitter(MarkupFormat.Mxp)) .With(new HtmlMarkupCodec()); } + + /// + /// As , but every tag rendered in + /// is held to — for output a + /// web browser renders. Pueblo and MXP output still carry tags as given: they go to MUD clients, not + /// to a browser, and some of what they need (a command link) is exactly what a browser policy + /// refuses. To hold those to a policy too, add new HtmlTagEmitter(format, policy) after this. + /// + public static MarkupRegistry WithHtml(this MarkupRegistry registry, HtmlTagPolicy htmlPolicy) + { + ArgumentNullException.ThrowIfNull(htmlPolicy); + return registry.WithHtml().With(new HtmlTagEmitter(MarkupFormat.Html, htmlPolicy)); + } } diff --git a/MarkupString.Html/HtmlTagPolicy.cs b/MarkupString.Html/HtmlTagPolicy.cs new file mode 100644 index 0000000..63ae738 --- /dev/null +++ b/MarkupString.Html/HtmlTagPolicy.cs @@ -0,0 +1,120 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using MarkupString.Ansi; +namespace MarkupString.Html; + +/// What happens to a tag's attributes when one of them is not allowed. +public enum HtmlAttributeViolation +{ + /// Only the attribute that failed is dropped. + DropAttribute, + + /// + /// Every attribute is dropped and the tag is kept bare, so no partial reading of the author's + /// intent survives — PennMUSH's rule for tagwrap(). + /// + DropAllAttributes, +} + +/// +/// Which tags and attributes an may carry. It turns an untrusted tag name and +/// raw attribute string into a checked with , and a +/// given one re-checks every tag it writes, so markup that arrived from +/// elsewhere — deserialised, or built with the unchecked — is held to it +/// too. +/// +/// +/// Every attribute that survives is re-written name="value" with its value encoded, whatever +/// the policy, so nothing in a value can end the attribute or the tag. +/// +public sealed record HtmlTagPolicy +{ + /// The tags allowed, ignoring case; allows any valid tag name. + public IReadOnlySet? AllowedTags { get; init; } + + /// The attributes allowed, ignoring case; allows any valid attribute name. + public IReadOnlySet? AllowedAttributes { get; init; } + + /// + /// The attributes whose value is an address a client may navigate to or load. Their value must be + /// one accepts, with no whitespace or control character + /// in it — a browser drops those before reading the scheme, so java script: would + /// otherwise pass as a relative address. + /// + public IReadOnlySet UrlAttributes { get; init; } = DefaultUrlAttributes; + + /// What happens to the attributes when one is not allowed. + public HtmlAttributeViolation OnViolation { get; init; } = HtmlAttributeViolation.DropAttribute; + + /// The attributes that carry an address in HTML. + public static IReadOnlySet DefaultUrlAttributes { get; } = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + "action", "background", "cite", "codebase", "data", "dynsrc", "formaction", "href", "longdesc", "lowsrc", + "poster", "src", "usemap", "xlink:href"); + + /// + /// Any tag and any attribute, so long as they are well formed; addresses are not checked. What it + /// adds over is that the name cannot smuggle anything in and every + /// value is encoded. + /// + public static HtmlTagPolicy WellFormed { get; } = new() { UrlAttributes = FrozenSet.Empty }; + + /// + /// Formatting that a web browser renders and cannot be made to run: text-level and block tags, + /// tables, lists, anchors and images; presentational attributes; and addresses with a safe scheme. + /// No script, style, iframe, form control or document-level tag, no on* + /// handler, no style attribute, and no javascript: or data: address. + /// + public static HtmlTagPolicy BrowserSafe { get; } = new() + { + AllowedTags = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + "a", "abbr", "acronym", "address", "b", "bdi", "bdo", "big", "blockquote", "br", "caption", "center", + "cite", "code", "col", "colgroup", "dd", "del", "dfn", "dir", "div", "dl", "dt", "em", "font", "h1", "h2", + "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li", "mark", "menu", "ol", "p", "pre", "q", "s", + "samp", "small", "span", "strike", "strong", "sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", + "time", "tr", "tt", "u", "ul", "var", "wbr"), + AllowedAttributes = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + "align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "class", "color", "cols", "colspan", + "datetime", "dir", "face", "height", "href", "lang", "rows", "rowspan", "size", "span", "src", "start", + "title", "type", "valign", "value", "width"), + }; + + /// + /// Builds the checked tag for and : false + /// when the name is not a tag name or not allowed; otherwise the tag with the attributes this + /// policy keeps, re-encoded. A malformed attribute string keeps none of them. + /// + public bool TryCreate(string tagName, string? attributes, [NotNullWhen(true)] out HtmlMarkup? markup) + { + ArgumentNullException.ThrowIfNull(tagName); + markup = null; + if (!HtmlMarkup.IsValidTagName(tagName) || AllowedTags is { } tags && !tags.Contains(tagName)) return false; + + markup = new HtmlMarkup(tagName, HtmlMarkup.TryParseAttributes(attributes, out var parsed) + ? HtmlMarkup.Join(Keep(parsed).ToArray()) + : null); + return true; + } + + /// + /// held to this policy: the same tag with only the attributes it keeps, + /// or when the tag itself is not allowed. + /// + public HtmlMarkup? Apply(HtmlMarkup markup) + { + ArgumentNullException.ThrowIfNull(markup); + return TryCreate(markup.TagName, markup.Attributes, out var held) ? held : null; + } + + private IEnumerable Keep(IReadOnlyList attributes) + { + var kept = attributes.Where(Allows).ToList(); + return OnViolation == HtmlAttributeViolation.DropAllAttributes && kept.Count != attributes.Count ? [] : kept; + } + + private bool Allows(HtmlAttribute attribute) => + (AllowedAttributes is null || AllowedAttributes.Contains(attribute.Name)) + && (!UrlAttributes.Contains(attribute.Name) || IsSafeAddress(attribute.Value)); + + private static bool IsSafeAddress(string value) => + !value.Any(c => char.IsControl(c) || char.IsWhiteSpace(c)) && UrlSafety.IsSafeNavigableUrl(value); +} diff --git a/MarkupString.Html/MarkupString.Html.csproj b/MarkupString.Html/MarkupString.Html.csproj index 4474565..7ea1d30 100644 --- a/MarkupString.Html/MarkupString.Html.csproj +++ b/MarkupString.Html/MarkupString.Html.csproj @@ -13,7 +13,7 @@ it back straight after. --> true 2.1.0 - Raw HTML tag markup for MarkupString — an MXP <send>, an anchor, a span — rendered as itself in the HTML, Pueblo and MXP formats, folded into terminal styling elsewhere, with the stylesheet for the ms-* classes the emitters write. + Raw HTML tag markup for MarkupString — an anchor, a pre, a span — rendered as itself in the HTML, Pueblo and MXP formats and folded into terminal styling elsewhere, with checked construction, tag policies for untrusted input, and the stylesheet for the ms-* classes the emitters write. diff --git a/MarkupString.Html/PublicAPI.Unshipped.txt b/MarkupString.Html/PublicAPI.Unshipped.txt index 7dc5c58..25df00f 100644 --- a/MarkupString.Html/PublicAPI.Unshipped.txt +++ b/MarkupString.Html/PublicAPI.Unshipped.txt @@ -1 +1,47 @@ #nullable enable +MarkupString.Html.HtmlAttribute +MarkupString.Html.HtmlAttribute.Deconstruct(out string! Name, out string! Value) -> void +MarkupString.Html.HtmlAttribute.Equals(MarkupString.Html.HtmlAttribute other) -> bool +MarkupString.Html.HtmlAttribute.HtmlAttribute(string! Name, string! Value) -> void +MarkupString.Html.HtmlAttribute.HtmlAttribute() -> void +MarkupString.Html.HtmlAttribute.Name.get -> string! +MarkupString.Html.HtmlAttribute.Name.init -> void +MarkupString.Html.HtmlAttribute.Value.get -> string! +MarkupString.Html.HtmlAttribute.Value.init -> void +MarkupString.Html.HtmlAttributeViolation +MarkupString.Html.HtmlAttributeViolation.DropAllAttributes = 1 -> MarkupString.Html.HtmlAttributeViolation +MarkupString.Html.HtmlAttributeViolation.DropAttribute = 0 -> MarkupString.Html.HtmlAttributeViolation +MarkupString.Html.HtmlTagEmitter.HtmlTagEmitter(MarkupString.MarkupFormat! format, MarkupString.Html.HtmlTagPolicy? policy) -> void +MarkupString.Html.HtmlTagEmitter.Policy.get -> MarkupString.Html.HtmlTagPolicy? +MarkupString.Html.HtmlTagPolicy +MarkupString.Html.HtmlTagPolicy.AllowedAttributes.get -> System.Collections.Generic.IReadOnlySet? +MarkupString.Html.HtmlTagPolicy.AllowedAttributes.init -> void +MarkupString.Html.HtmlTagPolicy.AllowedTags.get -> System.Collections.Generic.IReadOnlySet? +MarkupString.Html.HtmlTagPolicy.AllowedTags.init -> void +MarkupString.Html.HtmlTagPolicy.Apply(MarkupString.Html.HtmlMarkup! markup) -> MarkupString.Html.HtmlMarkup? +MarkupString.Html.HtmlTagPolicy.$() -> MarkupString.Html.HtmlTagPolicy! +MarkupString.Html.HtmlTagPolicy.Equals(MarkupString.Html.HtmlTagPolicy? other) -> bool +MarkupString.Html.HtmlTagPolicy.HtmlTagPolicy() -> void +MarkupString.Html.HtmlTagPolicy.OnViolation.get -> MarkupString.Html.HtmlAttributeViolation +MarkupString.Html.HtmlTagPolicy.OnViolation.init -> void +MarkupString.Html.HtmlTagPolicy.TryCreate(string! tagName, string? attributes, out MarkupString.Html.HtmlMarkup? markup) -> bool +MarkupString.Html.HtmlTagPolicy.UrlAttributes.get -> System.Collections.Generic.IReadOnlySet! +MarkupString.Html.HtmlTagPolicy.UrlAttributes.init -> void +~override MarkupString.Html.HtmlAttribute.Equals(object obj) -> bool +override MarkupString.Html.HtmlAttribute.GetHashCode() -> int +override MarkupString.Html.HtmlAttribute.ToString() -> string! +override MarkupString.Html.HtmlTagPolicy.Equals(object? obj) -> bool +override MarkupString.Html.HtmlTagPolicy.GetHashCode() -> int +override MarkupString.Html.HtmlTagPolicy.ToString() -> string! +static MarkupString.Html.HtmlAttribute.operator !=(MarkupString.Html.HtmlAttribute left, MarkupString.Html.HtmlAttribute right) -> bool +static MarkupString.Html.HtmlAttribute.operator ==(MarkupString.Html.HtmlAttribute left, MarkupString.Html.HtmlAttribute right) -> bool +static MarkupString.Html.HtmlMarkup.IsValidAttributeName(System.ReadOnlySpan name) -> bool +static MarkupString.Html.HtmlMarkup.IsValidTagName(System.ReadOnlySpan name) -> bool +static MarkupString.Html.HtmlMarkup.Tag(string! tagName, params System.ReadOnlySpan attributes) -> MarkupString.Html.HtmlMarkup! +static MarkupString.Html.HtmlMarkup.TryParseAttributes(string? attributes, out System.Collections.Generic.IReadOnlyList! parsed) -> bool +static MarkupString.Html.HtmlRegistration.WithHtml(this MarkupString.MarkupRegistry! registry, MarkupString.Html.HtmlTagPolicy! htmlPolicy) -> MarkupString.MarkupRegistry! +static MarkupString.Html.HtmlTagPolicy.BrowserSafe.get -> MarkupString.Html.HtmlTagPolicy! +static MarkupString.Html.HtmlTagPolicy.DefaultUrlAttributes.get -> System.Collections.Generic.IReadOnlySet! +static MarkupString.Html.HtmlTagPolicy.operator !=(MarkupString.Html.HtmlTagPolicy? left, MarkupString.Html.HtmlTagPolicy? right) -> bool +static MarkupString.Html.HtmlTagPolicy.operator ==(MarkupString.Html.HtmlTagPolicy? left, MarkupString.Html.HtmlTagPolicy? right) -> bool +static MarkupString.Html.HtmlTagPolicy.WellFormed.get -> MarkupString.Html.HtmlTagPolicy! diff --git a/MarkupString.Html/README.md b/MarkupString.Html/README.md index 65d0170..3a3f50e 100644 --- a/MarkupString.Html/README.md +++ b/MarkupString.Html/README.md @@ -1,8 +1,13 @@ # MarkupString.Html -Raw HTML tag markup for [`MarkupString`](https://www.nuget.org/packages/MarkupString): an MXP -``, an anchor, a `
` — carried as a layer over a span of text and written as -itself in the `Html`, `Pueblo` and `Mxp` formats. +Raw HTML tag markup for [`MarkupString`](https://www.nuget.org/packages/MarkupString): an anchor, a +`
`, a `
` — carried as a layer over a span of text and written as itself in the +`Html`, `Pueblo` and `Mxp` formats. + +A tag is written the same in all three, so use it for tags the three spell alike. A command link is +not one of them — Pueblo writes ``, MXP `` — so build that with +`MarkupString.Ansi`'s `AnsiMarkup.Create(linkUrl: ..., linkKind: LinkKind.Command)`, which each +format writes in its own dialect. Where a tag has no meaning — a terminal — it does not vanish silently: `b`, `strong`, `i`, `em`, `u`, `s`, `strike` and `del` fold into the run's terminal styling through @@ -23,10 +28,10 @@ using MarkupString.Html; MarkupRegistry.Default = MarkupRegistry.Empty.WithAnsi().WithHtml(); var text = MarkupText.Wrap( - HtmlMarkup.Create("send", "href=\"n\""), + HtmlMarkup.Tag("a", new HtmlAttribute("href", "https://example.org/")), MarkupText.Wrap(AnsiCodeParser.Parse("hr"), "north")); -text.Render(MarkupFormat.Html); // north +text.Render(MarkupFormat.Html); // north text.Render(MarkupFormat.Ansi); // \e[1;31mnorth\e[0m text.Render(MarkupFormat.Plain); // north @@ -35,7 +40,20 @@ var back = MarkupTextSerializer.Deserialize(json); ``` `WithAnsi()` must be applied as well: the terminal fold for `b`/`i`/`u`/`s` comes from that -package. Neither the tag name nor the attribute string is sanitised — validate what you put in one. +package. + +## Untrusted tags + +`HtmlMarkup.Create(name, attributes)` writes both exactly as given. For anything you did not write +yourself: + +- `HtmlMarkup.Tag(name, params attributes)` checks the name and encodes every value. +- `HtmlTagPolicy.TryCreate(name, rawAttributes, out markup)` reads a raw attribute string and keeps + only what the policy allows, re-encoded. `HtmlTagPolicy.BrowserSafe` allows formatting a browser + cannot be made to run; `HtmlTagPolicy.WellFormed` allows anything well formed. A policy is a + record — narrow one with `with { AllowedTags = ... }`. +- `WithHtml(HtmlTagPolicy.BrowserSafe)` holds every tag rendered in the `Html` format to the policy + as it is written, so markup that arrived deserialised or built with `Create` is held to it too. ## Styling diff --git a/MarkupString.Tests/Ansi/MxpSecureLineFramerTests.cs b/MarkupString.Tests/Ansi/MxpSecureLineFramerTests.cs new file mode 100644 index 0000000..c1eaa23 --- /dev/null +++ b/MarkupString.Tests/Ansi/MxpSecureLineFramerTests.cs @@ -0,0 +1,52 @@ +using MarkupString.Ansi; +namespace MarkupString.Tests.Ansi; + +/// +/// through : an MXP client reads tags only +/// on a line opened in secure mode, so every line that has content gets ESC[1z, and only +/// where the registry asked for it. +/// +public class MxpSecureLineFramerTests +{ + private const string Secure = "\e[1z"; + + private static readonly MarkupRegistry Plain = MarkupRegistry.Empty.WithAnsi(); + private static readonly MarkupRegistry Wire = MarkupRegistry.Empty.WithAnsi().WithMxpSecureLines(); + + private static readonly MarkupText Link = MarkupText.Wrap( + AnsiMarkup.Create(linkUrl: "north", linkKind: LinkKind.Command), "north"); + + [Test] + public async Task EveryLineWithContent_OpensInSecureMode() + { + var text = MarkupText.Concat(MarkupText.Concat(MarkupText.Plain("Exits:\n"), Link), MarkupText.Plain("\r\nlast")); + + await Assert.That(text.Render(MarkupFormat.Mxp, Wire)) + .IsEqualTo($"{Secure}Exits:\n{Secure}north\r\n{Secure}last"); + } + + [Test] + public async Task AnEmptyLine_GetsNothing() + { + await Assert.That(MarkupText.Plain("a\n\nb\r\n\r\nc\n").Render(MarkupFormat.Mxp, Wire)) + .IsEqualTo($"{Secure}a\n\n{Secure}b\r\n\r\n{Secure}c\n"); + } + + [Test] + public async Task OnlyMxp_AndOnlyWhereTheRegistryAskedForIt() + { + await Assert.That(Link.Render(MarkupFormat.Mxp, Plain)).DoesNotContain(Secure) + .Because("a render for a test, a log or a preview wants the tags alone"); + await Assert.That(Link.Render(MarkupFormat.Pueblo, Wire)).DoesNotContain(Secure); + await Assert.That(Link.Render(MarkupFormat.Ansi, Wire)).DoesNotContain(Secure); + } + + [Test] + public async Task TheRegistry_FindsTheLineFramer_AndAReplacementWins() + { + await Assert.That(Wire.FindLineFramer(MarkupFormat.Mxp)).IsSameReferenceAs(MxpSecureLineFramer.Instance); + await Assert.That(Plain.FindLineFramer(MarkupFormat.Mxp)).IsNull(); + await Assert.That(Wire.FindFramer(MarkupFormat.Mxp)).IsNull() + .Because("a line framer has its own slot, and does not displace a document framer"); + } +} diff --git a/MarkupString.Tests/Html/HtmlTagPolicyTests.cs b/MarkupString.Tests/Html/HtmlTagPolicyTests.cs new file mode 100644 index 0000000..68e9244 --- /dev/null +++ b/MarkupString.Tests/Html/HtmlTagPolicyTests.cs @@ -0,0 +1,165 @@ +using MarkupString.Ansi; +using MarkupString.Html; +namespace MarkupString.Tests.Html; + +/// +/// , and +/// : a tag built from untrusted parts cannot carry more than its policy +/// allows, and nothing in a value can end the attribute or the tag. +/// +public class HtmlTagPolicyTests +{ + private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml(); + + private static string Html(HtmlMarkup markup, string body = "x") => + MarkupText.Wrap(markup, body).Render(MarkupFormat.Html, Registry); + + // ── Tag: checked names, encoded values ───────────────────────────────────── + + [Test] + public async Task Tag_EncodesEveryValue() + { + var markup = HtmlMarkup.Tag("font", new HtmlAttribute("color", "red\">