From 632aff9c43095de5e75fb9f764ec6c34780005a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Thu, 3 Oct 2024 13:19:12 +0200 Subject: [PATCH 1/4] sup --- ...uentValidationLocalizationDtoExtensions.cs | 80 ------------------- .../FluentValidationStringExtensions.cs | 80 ++++++++++++++++++- ...dir.Domain.Dialogporten.Application.csproj | 1 - .../Content/ContentValueDtoValidator.cs | 53 ++++++++---- .../Create/CreateDialogCommandValidator.cs | 8 +- .../Update/UpdateDialogCommandValidator.cs | 8 +- 6 files changed, 123 insertions(+), 107 deletions(-) delete mode 100644 src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationLocalizationDtoExtensions.cs diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationLocalizationDtoExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationLocalizationDtoExtensions.cs deleted file mode 100644 index ae81bc0ae..000000000 --- a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationLocalizationDtoExtensions.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; -using FluentValidation; -using HtmlAgilityPack; -using Markdig; - -namespace Digdir.Domain.Dialogporten.Application.Common.Extensions.FluentValidation; - -internal static class FluentValidationLocalizationDtoExtensions -{ - private static readonly string[] AllowedTags = ["p", "a", "br", "em", "strong", "ul", "ol", "li"]; - private static readonly string ContainsValidHtmlError = - "Value contains unsupported HTML/markdown. The following tags are supported: " + - $"[{string.Join(",", AllowedTags.Select(x => '<' + x + '>'))}]. Tag attributes " + - "are not supported except for on '' which must contain a 'href' starting " + - "with 'https://'."; - - public static IRuleBuilderOptions ContainsValidHtml( - this IRuleBuilder ruleBuilder) - { - return ruleBuilder - .Must(x => x.Value is null || x.Value.HtmlAgilityPackCheck()) - .WithMessage(ContainsValidHtmlError); - } - - public static IRuleBuilderOptions ContainsValidMarkdown( - this IRuleBuilder ruleBuilder) - { - return ruleBuilder - .Must(x => x.Value is null || Markdown.ToHtml(x.Value).HtmlAgilityPackCheck()) - .WithMessage(ContainsValidHtmlError); - } - - private static bool HtmlAgilityPackCheck(this string html) - { - var doc = new HtmlDocument(); - doc.LoadHtml(html); - var nodes = doc.DocumentNode.DescendantsAndSelf(); - foreach (var node in nodes) - { - if (node.NodeType != HtmlNodeType.Element) continue; - - if (!AllowedTags.Contains(node.Name)) - { - return false; - } - // If the node is a hyperlink, it should only have a href attribute, - // and it must start with 'https://' - if (node.IsAnchorTag()) - { - if (!node.IsValidAnchorTag()) - { - return false; - } - - continue; - } - - if (node.Attributes.Count > 0) - { - return false; - } - } - return true; - } - - private static bool IsAnchorTag(this HtmlNode node) - { - const string anchorTag = "a"; - return node.Name == anchorTag; - } - - private static bool IsValidAnchorTag(this HtmlNode node) - { - const string https = "https://"; - const string href = "href"; - return node.Attributes.Count == 1 && - node.Attributes[href] is not null && - node.Attributes[href].Value.StartsWith(https, StringComparison.InvariantCultureIgnoreCase); - } -} diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationStringExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationStringExtensions.cs index 1a4f27e5b..c0cdc4a10 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationStringExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationStringExtensions.cs @@ -1,13 +1,91 @@ using FluentValidation; +using HtmlAgilityPack; namespace Digdir.Domain.Dialogporten.Application.Common.Extensions.FluentValidation; internal static class FluentValidationStringExtensions { + private static readonly string[] AllowedTags = ["p", "a", "br", "em", "strong", "ul", "ol", "li"]; + private static readonly string ContainsValidHtmlError = + "Value contains unsupported HTML. The following tags are supported: " + + $"[{string.Join(",", AllowedTags.Select(x => '<' + x + '>'))}]. Tag attributes " + + "are not supported except for on '' which must contain a 'href' starting " + + "with 'https://'."; + public static IRuleBuilderOptions IsValidUri(this IRuleBuilder ruleBuilder) { return ruleBuilder .Must(uri => uri is null || Uri.IsWellFormedUriString(uri, UriKind.RelativeOrAbsolute)) - .WithMessage("'{PropertyName}' is not a well formatted URI."); + .WithMessage("'{PropertyName}' is not a well-formatted URI."); + } + + public static IRuleBuilderOptions IsValidHttpsUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder + .Must(x => x is null || (Uri.TryCreate(x, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps)) + .WithMessage("'{PropertyName}' is not a well-formatted HTTPS URL."); + } + + public static IRuleBuilderOptions IsValidHttpsUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder + .Must(x => x is null || (x.IsAbsoluteUri && x.Scheme == Uri.UriSchemeHttps)) + .WithMessage("'{PropertyName}' is not a well-formatted HTTPS URL."); + } + + public static IRuleBuilderOptions ContainsValidHtml( + this IRuleBuilder ruleBuilder) + { + return ruleBuilder + .Must(x => x is null || x.HtmlAgilityPackCheck()) + .WithMessage(ContainsValidHtmlError); + } + + private static bool HtmlAgilityPackCheck(this string html) + { + var doc = new HtmlDocument(); + doc.LoadHtml(html); + var nodes = doc.DocumentNode.DescendantsAndSelf(); + foreach (var node in nodes) + { + if (node.NodeType != HtmlNodeType.Element) continue; + + if (!AllowedTags.Contains(node.Name)) + { + return false; + } + // If the node is a hyperlink, it should only have a href attribute, + // and it must start with 'https://' + if (node.IsAnchorTag()) + { + if (!node.IsValidAnchorTag()) + { + return false; + } + + continue; + } + + if (node.Attributes.Count > 0) + { + return false; + } + } + return true; + } + + private static bool IsAnchorTag(this HtmlNode node) + { + const string anchorTag = "a"; + return node.Name == anchorTag; + } + + private static bool IsValidAnchorTag(this HtmlNode node) + { + const string https = "https://"; + const string href = "href"; + return node.Attributes.Count == 1 && + node.Attributes[href] is not null && + node.Attributes[href].Value.StartsWith(https, StringComparison.InvariantCultureIgnoreCase); } } diff --git a/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj b/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj index 5a8ac78de..9f660923c 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj +++ b/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj @@ -9,7 +9,6 @@ - diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs index fb36a7ce0..2dc1a7ca4 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs @@ -15,6 +15,19 @@ namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content; // The validator is manually created in the Create and Update validators internal interface IIgnoreOnAssemblyScan; +// internal sealed class ContentFoo : AbstractValidator +// { +// public ContentFoo() +// { +// +// RuleFor(x => x.Value) +// .IsValidHttpsUrl() +// // .Must(x => Uri.TryCreate(x.Value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) +// // .IsValidHttpsUrl() +// .When((x, y) => x.MediaType is not null && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase)) +// // .WithMessage("{PropertyName} must be a valid HTTPS URL for embeddable content types"); +// } +// } internal sealed class ContentValueDtoValidator : AbstractValidator, IIgnoreOnAssemblyScan { private const string LegacyHtmlMediaType = "text/html"; @@ -27,13 +40,17 @@ public ContentValueDtoValidator(DialogTransmissionContentType contentType) .WithMessage($"{{PropertyName}} '{{PropertyValue}}' is not allowed for content type {contentType.Name}. " + $"Allowed media types are {string.Join(", ", contentType.AllowedMediaTypes.Select(x => $"'{x}'"))}"); - RuleForEach(x => x.Value) - .ContainsValidMarkdown() - .When(x => x.MediaType is MediaTypes.Markdown); - RuleForEach(x => x.Value) - .Must(x => Uri.TryCreate(x.Value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) - .When(x => x.MediaType is not null && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase)) - .WithMessage("{PropertyName} must be a valid HTTPS URL for embeddable content types"); + When(x => + x.MediaType is not null + && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase), + () => + { + RuleForEach(x => x.Value) + .ChildRules(x => x + .RuleFor(x => x.Value) + .IsValidHttpsUrl()); + }); + RuleFor(x => x.Value) .NotEmpty() .SetValidator(_ => new LocalizationDtosValidator(contentType.MaxLength)); @@ -47,16 +64,18 @@ public ContentValueDtoValidator(DialogContentType contentType, IUser? user = nul .Must(value => value is not null && allowedMediaTypes.Contains(value)) .WithMessage($"{{PropertyName}} '{{PropertyValue}}' is not allowed for content type {contentType.Name}. " + $"Allowed media types are {string.Join(", ", allowedMediaTypes.Select(x => $"'{x}'"))}"); - RuleForEach(x => x.Value) - .ContainsValidHtml() - .When(x => string.Equals(x.MediaType, LegacyHtmlMediaType, StringComparison.OrdinalIgnoreCase)); - RuleForEach(x => x.Value) - .ContainsValidMarkdown() - .When(x => x.MediaType is MediaTypes.Markdown); - RuleForEach(x => x.Value) - .Must(x => Uri.TryCreate(x.Value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) - .When(x => x.MediaType is not null && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase)) - .WithMessage("{PropertyName} must be a valid HTTPS URL for embeddable content types"); + + When(x => + x.MediaType is not null + && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase), + () => + { + RuleForEach(x => x.Value) + .ChildRules(x => x + .RuleFor(x => x.Value) + .IsValidHttpsUrl()); + }); + RuleFor(x => x.Value) .NotEmpty() .SetValidator(_ => new LocalizationDtosValidator(contentType.MaxLength)); diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs index e02ecf9ff..113924872 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs @@ -264,7 +264,7 @@ public CreateDialogDialogAttachmentUrlDtoValidator() { RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.ConsumerType) .IsInEnum(); @@ -291,7 +291,7 @@ public CreateDialogTransmissionAttachmentUrlDtoValidator() { RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.ConsumerType) .IsInEnum(); @@ -318,7 +318,7 @@ public CreateDialogDialogGuiActionDtoValidator( .MaximumLength(Constants.DefaultMaxStringLength); RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.AuthorizationAttribute) .MaximumLength(Constants.DefaultMaxStringLength); @@ -361,7 +361,7 @@ public CreateDialogDialogApiActionEndpointDtoValidator() .MaximumLength(Constants.DefaultMaxStringLength); RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.HttpMethod) .IsInEnum(); diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs index 978cafe81..1cf5b1c11 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs @@ -128,7 +128,7 @@ public UpdateDialogTransmissionAttachmentUrlDtoValidator() { RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.ConsumerType) .IsInEnum(); @@ -273,7 +273,7 @@ public UpdateDialogDialogAttachmentUrlDtoValidator() { RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.ConsumerType) .IsInEnum(); @@ -300,7 +300,7 @@ public UpdateDialogDialogGuiActionDtoValidator( .MaximumLength(Constants.DefaultMaxStringLength); RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.AuthorizationAttribute) .MaximumLength(Constants.DefaultMaxStringLength); @@ -345,7 +345,7 @@ public UpdateDialogDialogApiActionEndpointDtoValidator() .MaximumLength(Constants.DefaultMaxStringLength); RuleFor(x => x.Url) .NotNull() - .IsValidUri() + .IsValidHttpsUrl() .MaximumLength(Constants.DefaultMaxUriLength); RuleFor(x => x.HttpMethod) .IsInEnum(); From fb8e60115591d7df9326fe2f31f12988920cc994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Thu, 3 Oct 2024 13:39:35 +0200 Subject: [PATCH 2/4] fix test --- .../DialogGenerator.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs index a9bb58561..b8a524432 100644 --- a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs +++ b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs @@ -262,7 +262,7 @@ public static List GenerateFakeDialogActivities(i return new Faker() .RuleFor(o => o.Id, () => Uuid7.NewUuid7().ToGuid(true)) .RuleFor(o => o.CreatedAt, f => f.Date.Past()) - .RuleFor(o => o.ExtendedType, f => new Uri(f.Internet.UrlWithPath())) + .RuleFor(o => o.ExtendedType, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .RuleFor(o => o.Type, f => type ?? f.PickRandom(activityTypes)) .RuleFor(o => o.PerformedBy, f => new CreateDialogDialogActivityPerformedByActorDto { ActorType = ActorType.Values.PartyRepresentative, ActorName = f.Name.FullName() }) .RuleFor(o => o.Description, (f, o) => o.Type == DialogActivityType.Values.Information ? GenerateFakeLocalizations(f.Random.Number(4, 8)) : null) @@ -280,19 +280,19 @@ public static List GenerateFakeDialogApiActions( public static List GenerateFakeDialogApiActionEndpoints() { return new Faker() - .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath())) + .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .RuleFor(o => o.HttpMethod, f => f.PickRandom()) .RuleFor(o => o.Version, f => "v" + f.Random.Number(100, 999)) .RuleFor(o => o.Deprecated, f => f.Random.Bool()) - .RuleFor(o => o.RequestSchema, f => new Uri(f.Internet.UrlWithPath())) - .RuleFor(o => o.ResponseSchema, f => new Uri(f.Internet.UrlWithPath())) - .RuleFor(o => o.DocumentationUrl, f => new Uri(f.Internet.UrlWithPath())) + .RuleFor(o => o.RequestSchema, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) + .RuleFor(o => o.ResponseSchema, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) + .RuleFor(o => o.DocumentationUrl, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .Generate(new Randomizer().Number(min: 1, 4)); } public static string GenerateFakeProcessUri() { - return new Faker().Internet.UrlWithPath(); + return new Faker().Internet.UrlWithPath(Uri.UriSchemeHttps); } public static List GenerateFakeDialogGuiActions() @@ -317,7 +317,7 @@ public static List GenerateFakeDialogGuiActions( hasPrimary = true; return DialogGuiActionPriority.Values.Primary; }) - .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath())) + .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .RuleFor(o => o.Title, f => GenerateFakeLocalizations(f.Random.Number(1, 3))) .Generate(new Randomizer().Number(min: 1, 4)); } @@ -336,7 +336,7 @@ public static List GenerateFakeDialogAttachment public static List GenerateFakeDialogAttachmentUrls() { return new Faker() - .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath())) + .RuleFor(o => o.Url, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .RuleFor(o => o.ConsumerType, f => f.PickRandom()) .Generate(new Randomizer().Number(1, 3)); } From 1309c2ba226692fb6f095d247f35dd62c737b3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Fri, 4 Oct 2024 15:28:08 +0200 Subject: [PATCH 3/4] Remove comment --- .../V1/Common/Content/ContentValueDtoValidator.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs index 2dc1a7ca4..37199aa66 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs @@ -15,19 +15,6 @@ namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content; // The validator is manually created in the Create and Update validators internal interface IIgnoreOnAssemblyScan; -// internal sealed class ContentFoo : AbstractValidator -// { -// public ContentFoo() -// { -// -// RuleFor(x => x.Value) -// .IsValidHttpsUrl() -// // .Must(x => Uri.TryCreate(x.Value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) -// // .IsValidHttpsUrl() -// .When((x, y) => x.MediaType is not null && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase)) -// // .WithMessage("{PropertyName} must be a valid HTTPS URL for embeddable content types"); -// } -// } internal sealed class ContentValueDtoValidator : AbstractValidator, IIgnoreOnAssemblyScan { private const string LegacyHtmlMediaType = "text/html"; From c31e6c2cfe46d4edd32c47c64e626e73aff5fb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Fri, 4 Oct 2024 15:36:23 +0200 Subject: [PATCH 4/4] Use ordinal ignore case --- .../Features/V1/Common/Content/ContentValueDtoValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs index 37199aa66..5c187d2a0 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDtoValidator.cs @@ -29,7 +29,7 @@ public ContentValueDtoValidator(DialogTransmissionContentType contentType) When(x => x.MediaType is not null - && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.InvariantCultureIgnoreCase), + && x.MediaType.StartsWith(MediaTypes.EmbeddablePrefix, StringComparison.OrdinalIgnoreCase), () => { RuleForEach(x => x.Value)