Skip to content
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

chore: Add HTTPs restriction for URLs on Attachments, ApiActions, GuiActions #1222

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,13 +1,91 @@
using FluentValidation;
using HtmlAgilityPack;
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

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"];
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
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 '<a>' which must contain a 'href' starting " +
"with 'https://'.";

public static IRuleBuilderOptions<T, string?> IsValidUri<T>(this IRuleBuilder<T, string?> 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<T, string?> IsValidHttpsUrl<T>(this IRuleBuilder<T, string?> 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<T, Uri?> IsValidHttpsUrl<T>(this IRuleBuilder<T, Uri?> 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<T, string?> ContainsValidHtml<T>(
this IRuleBuilder<T, string?> ruleBuilder)
{
return ruleBuilder
.Must(x => x is null || x.HtmlAgilityPackCheck())
.WithMessage(ContainsValidHtmlError);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

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;
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
// 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;
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}
return true;
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

private static bool IsAnchorTag(this HtmlNode node)
{
const string anchorTag = "a";
return node.Name == anchorTag;
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

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);
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="13.0.1"/>
<PackageReference Include="HtmlAgilityPack" Version="1.11.66"/>
<PackageReference Include="Markdig.Signed" Version="0.37.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0"/>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalizationDto>
// {
// 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");
// }
// }
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
internal sealed class ContentValueDtoValidator : AbstractValidator<ContentValueDto>, IIgnoreOnAssemblyScan
{
private const string LegacyHtmlMediaType = "text/html";
Expand All @@ -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),
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
() =>
{
RuleForEach(x => x.Value)
.ChildRules(x => x
.RuleFor(x => x.Value)
.IsValidHttpsUrl());
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
});

oskogstad marked this conversation as resolved.
Show resolved Hide resolved
RuleFor(x => x.Value)
.NotEmpty()
.SetValidator(_ => new LocalizationDtosValidator(contentType.MaxLength));
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ public CreateDialogDialogAttachmentUrlDtoValidator()
{
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.ConsumerType)
.IsInEnum();
Expand All @@ -291,7 +291,7 @@ public CreateDialogTransmissionAttachmentUrlDtoValidator()
{
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.ConsumerType)
.IsInEnum();
Expand All @@ -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);
Expand Down Expand Up @@ -361,7 +361,7 @@ public CreateDialogDialogApiActionEndpointDtoValidator()
.MaximumLength(Constants.DefaultMaxStringLength);
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.HttpMethod)
.IsInEnum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public UpdateDialogTransmissionAttachmentUrlDtoValidator()
{
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.ConsumerType)
.IsInEnum();
Expand Down Expand Up @@ -273,7 +273,7 @@ public UpdateDialogDialogAttachmentUrlDtoValidator()
{
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.ConsumerType)
.IsInEnum();
Expand All @@ -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);
Expand Down Expand Up @@ -345,7 +345,7 @@ public UpdateDialogDialogApiActionEndpointDtoValidator()
.MaximumLength(Constants.DefaultMaxStringLength);
RuleFor(x => x.Url)
.NotNull()
.IsValidUri()
.IsValidHttpsUrl()
.MaximumLength(Constants.DefaultMaxUriLength);
RuleFor(x => x.HttpMethod)
.IsInEnum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ public static List<CreateDialogDialogActivityDto> GenerateFakeDialogActivities(i
return new Faker<CreateDialogDialogActivityDto>()
.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)
Expand All @@ -280,19 +280,19 @@ public static List<CreateDialogDialogApiActionDto> GenerateFakeDialogApiActions(
public static List<CreateDialogDialogApiActionEndpointDto> GenerateFakeDialogApiActionEndpoints()
{
return new Faker<CreateDialogDialogApiActionEndpointDto>()
.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<HttpVerb.Values>())
.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<CreateDialogDialogGuiActionDto> GenerateFakeDialogGuiActions()
Expand All @@ -317,7 +317,7 @@ public static List<CreateDialogDialogGuiActionDto> 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));
}
Expand All @@ -336,7 +336,7 @@ public static List<CreateDialogDialogAttachmentDto> GenerateFakeDialogAttachment
public static List<CreateDialogDialogAttachmentUrlDto> GenerateFakeDialogAttachmentUrls()
{
return new Faker<CreateDialogDialogAttachmentUrlDto>()
.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<AttachmentUrlConsumerType.Values>())
.Generate(new Randomizer().Number(1, 3));
}
Expand Down
Loading