fix(commons): reject comma-joined enum tokens in strict audit query parsers (arch-review 2026-08-19 P6-3, register row 99 defect)

This commit is contained in:
Joseph Doherty
2026-08-19 14:33:22 -04:00
parent d18f344e66
commit 91fcb5b951
2 changed files with 75 additions and 2 deletions
@@ -79,7 +79,8 @@ public static class AuditQueryParamParsers
/// the filter dimension stays unconstrained.
/// </summary>
/// <remarks>
/// Register row 99. Two traps this closes that a bare
/// Register row 99 (closure defect fixed by arch-review 2026-08-19 P6-3). Three
/// traps this closes that a bare
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not:
/// <list type="number">
/// <item><b>Numeric strings.</b> <c>TryParse</c> happily converts <c>"7"</c> —
@@ -87,6 +88,17 @@ public static class AuditQueryParamParsers
/// is a caller-facing name-based contract, never an ordinal one, so any value
/// starting with a digit or sign is rejected outright. Enum member names can
/// never start with those characters, so the check costs no legitimate input.</item>
/// <item><b>Comma-joined tokens.</b> <c>TryParse</c> splits ANY comma-separated
/// string on <c>,</c> and bitwise-ORs the pieces together — regardless of
/// whether <typeparamref name="TEnum"/> is a <c>[Flags]</c> enum. For a dense
/// 0-based enum like <c>AuditStatus</c> that composite almost always collides
/// with a real, but WRONG, member (<c>"Forwarded,Attempted"</c> = 1|2 = 3 =
/// <c>Delivered</c>), and it smuggles an ordinal past the numeric guard above
/// (<c>"Failed,7"</c> = 7 = <c>Skipped</c>). A single token containing <c>,</c>
/// is therefore rejected outright, before <c>TryParse</c> ever sees it — a
/// value that legitimately arrives as multiple tokens (a repeated query
/// parameter, or an already-split list) is unaffected, since each of those
/// tokens is comma-free on its own.</item>
/// <item><b>Undefined composites.</b> <see cref="Enum.IsDefined{TEnum}(TEnum)"/>
/// catches anything that parsed to a value with no declared member.</item>
/// </list>
@@ -95,7 +107,7 @@ public static class AuditQueryParamParsers
/// <param name="rawValues">Raw query-parameter string values to parse; may be null.</param>
/// <param name="parameterName">Query-parameter name, used in the error message (e.g. <c>"status"</c>).</param>
/// <returns>A non-empty list of parsed values, or <c>null</c> if no values were supplied.</returns>
/// <exception cref="AuditFilterParseException">A supplied value is not a member name of <typeparamref name="TEnum"/>.</exception>
/// <exception cref="AuditFilterParseException">A supplied value is not a member name of <typeparamref name="TEnum"/>, or a single token contains a comma.</exception>
public static IReadOnlyList<TEnum>? ParseEnumListStrict<TEnum>(
IEnumerable<string?>? rawValues, string parameterName)
where TEnum : struct, Enum
@@ -115,6 +127,7 @@ public static class AuditQueryParamParsers
var trimmed = raw.Trim();
if (LooksNumeric(trimmed)
|| trimmed.Contains(',')
|| !Enum.TryParse<TEnum>(trimmed, ignoreCase: true, out var value)
|| !Enum.IsDefined(value))
{