From 7bae37da617ea85cb2cc52869b03fff0a6eab9ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:28:26 +0000 Subject: [PATCH] Keep rejected identifier values out of validation messages [patch] A failed SemanticString creation quoted the whole canonical value in its ArgumentException, so a mistyped card number, a tampered JWT or an IBAN went straight into logs. The message also dropped the reason each validation attribute gives. Add a protected internal IncludeValueInErrors hook, set to false on CreditCardNumber, JwtToken and Iban, and append the errors of every rejecting attribute to the message. Fixes #333 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QbGcYckSzgr6jPyoNYhWvA --- .../CreditCardNumber.cs | 3 ++ Semantics.Strings.Identifiers/Iban.cs | 3 ++ Semantics.Strings.Identifiers/JwtToken.cs | 2 + Semantics.Strings/SemanticString.cs | 29 ++++++++++- .../SemanticStringValidationAttributes.cs | 37 +++++++++++++ .../Identifiers/ValidationMessageTests.cs | 52 +++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 Semantics.Test/Identifiers/ValidationMessageTests.cs diff --git a/Semantics.Strings.Identifiers/CreditCardNumber.cs b/Semantics.Strings.Identifiers/CreditCardNumber.cs index a5f2b06a..5a3812fa 100644 --- a/Semantics.Strings.Identifiers/CreditCardNumber.cs +++ b/Semantics.Strings.Identifiers/CreditCardNumber.cs @@ -20,4 +20,7 @@ protected override string MakeCanonical(string input) Ensure.NotNull(input); return input.Replace(" ", string.Empty).Replace("-", string.Empty); } + + /// A rejected value is sensitive, so it is left out of the validation exception message. + protected override bool IncludeValueInErrors => false; } diff --git a/Semantics.Strings.Identifiers/Iban.cs b/Semantics.Strings.Identifiers/Iban.cs index ff44ac15..bb0b36b7 100644 --- a/Semantics.Strings.Identifiers/Iban.cs +++ b/Semantics.Strings.Identifiers/Iban.cs @@ -31,4 +31,7 @@ protected override string MakeCanonical(string input) return canonical.ToString(); } + + /// A rejected value is sensitive, so it is left out of the validation exception message. + protected override bool IncludeValueInErrors => false; } diff --git a/Semantics.Strings.Identifiers/JwtToken.cs b/Semantics.Strings.Identifiers/JwtToken.cs index da11da1b..d7a59f17 100644 --- a/Semantics.Strings.Identifiers/JwtToken.cs +++ b/Semantics.Strings.Identifiers/JwtToken.cs @@ -13,4 +13,6 @@ namespace ktsu.Semantics.Strings.Identifiers; [IsJwtToken] public sealed record JwtToken : SemanticString { + /// A rejected value is sensitive, so it is left out of the validation exception message. + protected override bool IncludeValueInErrors => false; } diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs index fcbb33ae..0eacd4cb 100644 --- a/Semantics.Strings/SemanticString.cs +++ b/Semantics.Strings/SemanticString.cs @@ -421,6 +421,17 @@ public TDest As() /// public virtual bool ValidateAttributes() => AttributeValidation.ValidateAttributes(this, GetType()); + /// + /// Gets whether the exception thrown for a value that fails validation quotes that value. + /// + /// + /// by default. Override it to return on a type whose + /// values are sensitive, such as a card number or a credential, so that a rejected value does not + /// reach logs, error trackers or error responses through the exception message. The message still + /// names the type and the reasons the validation attributes give. + /// + protected internal virtual bool IncludeValueInErrors => true; + // Note: Explicit conversion operators were removed in favor of the .As() extension method pattern // for better discoverability, consistency, and clearer intent at call sites. @@ -635,11 +646,27 @@ private static TDest PerformValidation(TDest? value) { if (value == null || !value.IsValid()) { - throw new ArgumentException(message: $"Cannot convert \"{value}\" to {typeof(TDest).Name}"); + throw new ArgumentException(message: ValidationFailureMessage(value)); } return value; } + /// + /// Builds the message for a value that failed validation: the rejected value, unless the type opts out + /// through , followed by the reasons its validation attributes give. + /// + /// The semantic string type being validated. + /// The semantic string instance that failed validation. + /// The exception message. + private static string ValidationFailureMessage(TDest? value) + where TDest : SemanticString + { + string subject = value is null || value.IncludeValueInErrors ? $"\"{value}\"" : "the value"; + string message = $"Cannot convert {subject} to {typeof(TDest).Name}"; + string[] errors = value is null ? [] : [.. AttributeValidation.GetValidationErrors(value, value.GetType())]; + return errors.Length == 0 ? message : $"{message}: {string.Join(" ", errors)}"; + } + /// /// Attempts to create a new instance of this semantic string type from a string value without throwing exceptions. /// diff --git a/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs b/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs index 82913976..360c80bd 100644 --- a/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs +++ b/Semantics.Strings/Validation/SemanticStringValidationAttributes.cs @@ -3,6 +3,8 @@ namespace ktsu.Semantics.Strings; using System; +using System.Collections.Generic; +using System.Linq; /// /// Helper class for validating using attributes @@ -24,4 +26,39 @@ public static bool ValidateAttributes(ISemanticString semanticString, Type type) IValidationStrategy strategy = ValidationStrategyFactory.CreateStrategy(type); return strategy.Validate(semanticString, type); } + + /// + /// Gets the reasons the validation attributes defined on a type give for rejecting a SemanticString. + /// + /// The SemanticString that failed validation + /// The type of the SemanticString, which contains the validation attributes + /// The error messages of every attribute that rejects the string, in declaration order + /// + /// Under these are the attributes that failed; under + /// every attribute failed, so this is the union of their errors. + /// An attribute that reports no reason of its own is named instead. + /// + public static IEnumerable GetValidationErrors(ISemanticString semanticString, Type type) + { + Ensure.NotNull(type); + + foreach (SemanticStringValidationAttribute attribute in type.GetCustomAttributes(typeof(SemanticStringValidationAttribute), true) + .Cast() + .Where(attribute => !attribute.Validate(semanticString))) + { + string[] errors = attribute is NativeSemanticStringValidationAttribute native + ? [.. native.GetValidationErrors(semanticString)] + : []; + if (errors.Length == 0) + { + yield return $"{attribute.GetType().Name} validation failed."; + continue; + } + + foreach (string error in errors) + { + yield return error; + } + } + } } diff --git a/Semantics.Test/Identifiers/ValidationMessageTests.cs b/Semantics.Test/Identifiers/ValidationMessageTests.cs new file mode 100644 index 00000000..17e63dcc --- /dev/null +++ b/Semantics.Test/Identifiers/ValidationMessageTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Identifiers; + +using ktsu.Semantics.Strings.Identifiers; + +/// +/// Issue #333: a rejected sensitive value stays out of the exception message, and the message says +/// which rule the value broke. +/// +[TestClass] +public sealed class ValidationMessageTests +{ + [TestMethod] + public void CreditCardNumber_Rejected_MessageOmitsValueAndGivesReason() + { + ArgumentException ex = Assert.ThrowsExactly(() => CreditCardNumber.Create("4111 1111 1111 1112")); + + Assert.DoesNotContain("4111111111111112", ex.Message); + Assert.Contains(nameof(CreditCardNumber), ex.Message); + Assert.Contains("Luhn", ex.Message); + } + + [TestMethod] + public void JwtToken_Rejected_MessageOmitsValueAndGivesReason() + { + const string token = "WyJhIl0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig"; + ArgumentException ex = Assert.ThrowsExactly(() => JwtToken.Create(token)); + + Assert.DoesNotContain("eyJzdWIiOiIxMjM0NTY3ODkwIn0", ex.Message); + Assert.Contains("JWT header", ex.Message); + } + + [TestMethod] + public void Iban_Rejected_MessageOmitsValueAndGivesReason() + { + ArgumentException ex = Assert.ThrowsExactly(() => Iban.Create("GB82 WEST 1234 5698 7654 33")); + + Assert.DoesNotContain("GB82WEST12345698765433", ex.Message); + Assert.Contains("mod-97", ex.Message); + } + + [TestMethod] + public void Uuid_Rejected_MessageKeepsValueAndGivesReason() + { + ArgumentException ex = Assert.ThrowsExactly(() => Uuid.Create("not-a-uuid")); + + Assert.Contains("not-a-uuid", ex.Message); + Assert.Contains(nameof(Uuid), ex.Message); + Assert.Contains("RFC 4122", ex.Message); + } +}