diff --git a/Directory.Packages.props b/Directory.Packages.props index 6fdf625a4..448f8b957 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/Core/Grand.Infrastructure/Configuration/SecurityConfig.cs b/src/Core/Grand.Infrastructure/Configuration/SecurityConfig.cs index 2b2a2b63b..db88abcbf 100644 --- a/src/Core/Grand.Infrastructure/Configuration/SecurityConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/SecurityConfig.cs @@ -93,4 +93,21 @@ public class SecurityConfig /// The value is embedded in each stored hash, so raising it later does not break existing hashes. /// public int PasswordHashIterations { get; set; } + + /// + /// Hosts whose iframes survive HTML sanitization of rich-text content (product descriptions, blog posts, pages). + /// An iframe pointing anywhere else is removed, because its src is otherwise attacker-controlled. + /// Matching is case-insensitive on the host only; a leading "*." matches any subdomain. + /// Leave empty to fall back to the built-in video-embed defaults; set to a single empty entry to block every iframe. + /// + public string[] SanitizerAllowedIframeHosts { get; set; } + + /// + /// Gets or sets a value indicating whether [SanitizeHtml] and [NoHtml] reject markup on save. Default true. + /// This is an operational escape hatch, not a security setting: turn it off only temporarily, if the + /// allowlist is found to reject legitimate content in production, while a fix is prepared - every field + /// these attributes guard (Vendor/Store-manager-editable rich text rendered unencoded via Html.Raw/v-html) + /// goes back to accepting raw, unsanitized HTML the moment this is false. Re-enable as soon as possible. + /// + public bool EnableHtmlSanitization { get; set; } = true; } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Grand.Infrastructure.csproj b/src/Core/Grand.Infrastructure/Grand.Infrastructure.csproj index 6c0003b5e..f33293af1 100644 --- a/src/Core/Grand.Infrastructure/Grand.Infrastructure.csproj +++ b/src/Core/Grand.Infrastructure/Grand.Infrastructure.csproj @@ -3,6 +3,7 @@ + diff --git a/src/Core/Grand.Infrastructure/Security/HtmlSanitizationService.cs b/src/Core/Grand.Infrastructure/Security/HtmlSanitizationService.cs new file mode 100644 index 000000000..1de4e228e --- /dev/null +++ b/src/Core/Grand.Infrastructure/Security/HtmlSanitizationService.cs @@ -0,0 +1,243 @@ +using Ganss.Xss; +using Grand.Infrastructure.Configuration; + +namespace Grand.Infrastructure.Security; + +/// +/// Allowlist-based implementation over HtmlSanitizer. Sanitizes nothing itself - it runs the library's +/// allowlist and reports whether anything would have been removed, which is all a rejecting +/// needs. +/// Registered as a singleton, but holds no mutable shared state: the allowlists below ( +/// instances returned by a throwaway HtmlSanitizer during construction) are built once and never mutated +/// afterwards, so concurrent calls can read them freely. Each detection call builds its own short-lived +/// HtmlSanitizer around those shared, read-only allowlists, with event handlers that close over a local +/// "was anything removed" flag - so two concurrent calls can never observe or overwrite each other's result, +/// and no lock is needed. +/// is an operational kill switch, read once at +/// construction: when false, both detection methods report "nothing disallowed" unconditionally, so +/// [SanitizeHtml]/[NoHtml] accept any input. Off by exception only - see that property's remarks. +/// +public class HtmlSanitizationService : IHtmlSanitizationService +{ + /// + /// Hosts allowed to be framed when the configuration does not name any. These are the embed hosts a store + /// realistically pastes into a page body; anything else has to be opted into explicitly. + /// + private static readonly string[] DefaultAllowedIframeHosts = + [ + "youtube.com", "*.youtube.com", + "youtube-nocookie.com", "*.youtube-nocookie.com", + "vimeo.com", "*.vimeo.com", + "google.com", "*.google.com" + ]; + + /// + /// Tags that are in the library defaults but have no place in store content: they either collect input or + /// submit it somewhere. A form rendered inside the admin panel is a credible phishing surface. + /// + private static readonly string[] InteractiveTags = + [ + "form", "input", "button", "textarea", "select", "option", "optgroup", "label", "fieldset", "legend", + "datalist", "output", "progress", "meter" + ]; + + private readonly string[] _allowedIframeHosts; + private readonly bool _enabled; + private readonly HtmlSanitizerOptions _richTextOptions; + + public HtmlSanitizationService(SecurityConfig securityConfig) + { + //defaults to enabled: a missing SecurityConfig or a config binder that never touched this property + //must fail closed (sanitize), not open (accept anything) + _enabled = securityConfig?.EnableHtmlSanitization ?? true; + _allowedIframeHosts = ResolveAllowedIframeHosts(securityConfig); + _richTextOptions = BuildRichTextOptions(); + } + + /// + /// Built once from a throwaway sanitizer's defaults, then reused - unmutated - as the constructor + /// argument for every sanitizer built per detection call below. HtmlSanitizerOptions only fills in what + /// it is given (verified: leaving UriAttributes/AllowedCssProperties/AllowedAtRules unset produces empty + /// sets, not the library defaults), so every allowlist a plain `new HtmlSanitizer()` would carry has to + /// be copied across explicitly, not just the ones this class customizes. + /// + private static HtmlSanitizerOptions BuildRichTextOptions() + { + var defaults = new HtmlSanitizer(); + + //the library defaults already exclude script, object, embed, svg, style and every on* handler, because + //only allowlisted tags and attributes survive; these are the deviations from those defaults + foreach (var tag in InteractiveTags) defaults.AllowedTags.Remove(tag); + + //framed media, restricted to allowlisted hosts by OnFilterUrl below + defaults.AllowedTags.Add("iframe"); + defaults.AllowedAttributes.Add("allowfullscreen"); + defaults.AllowedAttributes.Add("frameborder"); + defaults.AllowedAttributes.Add("allow"); + defaults.AllowedAttributes.Add("loading"); + + //editors emit class names for tables, images and alignment + defaults.AllowedAttributes.Add("class"); + + defaults.AllowedSchemes.Add("mailto"); + defaults.AllowedSchemes.Add("tel"); + //data: is accepted only for images, and only on img/src - enforced in OnFilterUrl + defaults.AllowedSchemes.Add("data"); + + return new HtmlSanitizerOptions { + AllowedTags = defaults.AllowedTags, + AllowedAttributes = defaults.AllowedAttributes, + AllowedSchemes = defaults.AllowedSchemes, + AllowedCssClasses = defaults.AllowedClasses, + AllowedCssProperties = defaults.AllowedCssProperties, + AllowedAtRules = defaults.AllowedAtRules, + UriAttributes = defaults.UriAttributes, + UriListAttributes = defaults.UriListAttributes, + AllowCssCustomProperties = defaults.AllowCssCustomProperties, + //data-* is not allowlisted per attribute, so it cannot be vetted; it is also read by the storefront + //scripts, which makes it a way to influence behaviour from stored content + AllowDataAttributes = false + }; + } + + public bool ContainsDisallowedRichText(string html) + { + if (string.IsNullOrWhiteSpace(html)) return false; + if (!_enabled) return false; + + var disallowedContentSeen = false; + + void MarkDisallowed(object sender, EventArgs e) + { + disallowedContentSeen = true; + } + + void OnFilterUrl(object sender, FilterUrlEventArgs e) + { + var url = e.SanitizedUrl ?? e.OriginalUrl; + if (string.IsNullOrWhiteSpace(url)) return; + + var tagName = e.Tag?.NodeName; + + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + //data:text/html is a script execution vector; an inline image is not + var isInlineImage = string.Equals(tagName, "IMG", StringComparison.OrdinalIgnoreCase) && + url.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase); + if (!isInlineImage) + { + e.SanitizedUrl = null; + disallowedContentSeen = true; + } + + return; + } + + if (string.Equals(tagName, "IFRAME", StringComparison.OrdinalIgnoreCase) && + !IsAllowedIframeUrl(url, _allowedIframeHosts)) + { + e.SanitizedUrl = null; + disallowedContentSeen = true; + } + } + + var sanitizer = new HtmlSanitizer(_richTextOptions); + sanitizer.RemovingTag += MarkDisallowed; + sanitizer.RemovingAttribute += MarkDisallowed; + sanitizer.RemovingStyle += MarkDisallowed; + sanitizer.RemovingAtRule += MarkDisallowed; + sanitizer.RemovingCssClass += MarkDisallowed; + sanitizer.RemovingComment += MarkDisallowed; + sanitizer.FilterUrl += OnFilterUrl; + + var document = sanitizer.SanitizeDom(html); + + //a literal // tag in the input is merged into the parser's own document root, which + //sits outside the per-element sanitization loop - RemovingAttribute never fires for its own attributes + //even though everything inside it is correctly sanitized (verified: survives + //with the handler intact). Rich-text content is always a fragment and never legitimately needs + //attributes on that wrapper, so any attribute there means the raw input smuggled one in. + if (HasOwnAttributes(document.DocumentElement) || HasOwnAttributes(document.Head) || + HasOwnAttributes(document.Body)) + disallowedContentSeen = true; + + return disallowedContentSeen; + } + + public bool ContainsMarkup(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + if (!_enabled) return false; + + var disallowedContentSeen = false; + + void MarkDisallowed(object sender, EventArgs e) + { + disallowedContentSeen = true; + } + + var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions { + AllowedTags = new HashSet(), + AllowedAttributes = new HashSet(), + AllowedSchemes = new HashSet(), + AllowedCssProperties = new HashSet(), + UriAttributes = new HashSet() + }); + sanitizer.RemovingTag += MarkDisallowed; + sanitizer.RemovingComment += MarkDisallowed; + + var document = sanitizer.SanitizeDom(text); + + //see the identical guard in ContainsDisallowedRichText - a literal // tag merges + //into the document root and its own attributes never reach RemovingAttribute + if (HasOwnAttributes(document.DocumentElement) || HasOwnAttributes(document.Head) || + HasOwnAttributes(document.Body)) + disallowedContentSeen = true; + + return disallowedContentSeen; + } + + private static bool HasOwnAttributes(AngleSharp.Dom.IElement element) + { + return element is not null && element.Attributes.Length > 0; + } + + private static string[] ResolveAllowedIframeHosts(SecurityConfig securityConfig) + { + //absent configuration means "use the defaults"; a configured but empty list means "frame nothing" + if (securityConfig?.SanitizerAllowedIframeHosts is null) return DefaultAllowedIframeHosts; + + return securityConfig.SanitizerAllowedIframeHosts + .Where(host => !string.IsNullOrWhiteSpace(host)) + .Select(host => host.Trim()) + .ToArray(); + } + + private static bool IsAllowedIframeUrl(string url, string[] allowedIframeHosts) + { + if (allowedIframeHosts.Length == 0) return false; + + //protocol-relative ("//host/path") is parsed as a relative Uri by .NET - IsAbsoluteUri is false - but a + //browser resolves it against the current page's scheme, i.e. as an absolute url to an arbitrary host. + //Reject it before the relative-url fast path below would otherwise wave it through as same-origin. + if (url.StartsWith("//", StringComparison.Ordinal)) return false; + + if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return false; + + //a genuinely relative url (no leading "//") cannot leave this origin - it is how the file manager + //inserts self-hosted video + if (!uri.IsAbsoluteUri) return true; + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return false; + + return allowedIframeHosts.Any(allowed => IsHostMatch(uri.Host, allowed)); + } + + private static bool IsHostMatch(string host, string allowed) + { + if (allowed.StartsWith("*.", StringComparison.Ordinal)) + return host.EndsWith(allowed[1..], StringComparison.OrdinalIgnoreCase); + + return string.Equals(host, allowed, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Core/Grand.Infrastructure/Security/IHtmlSanitizationService.cs b/src/Core/Grand.Infrastructure/Security/IHtmlSanitizationService.cs new file mode 100644 index 000000000..71be74555 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Security/IHtmlSanitizationService.cs @@ -0,0 +1,28 @@ +namespace Grand.Infrastructure.Security; + +/// +/// Detects markup that falls outside an allowlist. Content edited by a vendor, a store manager, or any +/// non-superadmin account is untrusted: it is rendered back into the administration panel, so markup that +/// survives here executes in an administrator's session. +/// This is detection, not rewriting: callers reject the value (see +/// / ) rather than silently cleaning it, so a +/// rejected save always shows the editor what needs to change. +/// +public interface IHtmlSanitizationService +{ + /// + /// True when the rich-text allowlist would remove something from - a tag, + /// attribute, style rule, class, comment, or url (script, iframe to an unlisted host, javascript:, etc.) + /// that can execute or navigate on its own. False for markup an editor legitimately produces, even if the + /// allowlist would reformat it (e.g. an implied <tbody> made explicit). + /// + /// Untrusted markup; may be null + bool ContainsDisallowedRichText(string html); + + /// + /// True when contains any HTML markup at all. For fields that are never rich + /// text: meta titles, meta keywords, meta descriptions, admin comments, attribute names. + /// + /// Untrusted text; may be null + bool ContainsMarkup(string text); +} diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs index 8bee26d0b..0dd1a13a5 100644 --- a/src/Core/Grand.Infrastructure/StartupBase.cs +++ b/src/Core/Grand.Infrastructure/StartupBase.cs @@ -7,6 +7,7 @@ using Grand.Infrastructure.Modules; using Grand.Infrastructure.Plugins; using Grand.Infrastructure.Roslyn; +using Grand.Infrastructure.Security; using Grand.Infrastructure.TypeConverters; using Grand.Infrastructure.TypeSearch; using Grand.Infrastructure.Validators; @@ -175,6 +176,9 @@ private static IMvcCoreBuilder RegisterApplication(IServiceCollection services, InitDatabase(services, configuration); + //resolved by SanitizeHtmlAttribute/NoHtmlAttribute via ValidationContext.GetService - no filter needed, + //ASP.NET Core's built-in model validation already invokes DataAnnotations attributes on every bind + services.AddSingleton(); services.AddTransient(); var mvcCoreBuilder = services.AddMvcCore(options => { diff --git a/src/Core/Grand.Infrastructure/Validators/NoHtmlAttribute.cs b/src/Core/Grand.Infrastructure/Validators/NoHtmlAttribute.cs new file mode 100644 index 000000000..d72503031 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Validators/NoHtmlAttribute.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using Grand.Infrastructure.Security; + +namespace Grand.Infrastructure.Validators; + +/// +/// Rejects a bound string property that contains any HTML markup at all - for a property that is never rich +/// text: a meta tag, a name, an internal comment. See for why detection +/// goes through resolved via . +/// +[AttributeUsage(AttributeTargets.Property)] +public class NoHtmlAttribute() : ValidationAttribute("{0} must be plain text and cannot contain HTML markup.") +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value is not string text || string.IsNullOrWhiteSpace(text)) return ValidationResult.Success; + + var sanitizationService = (IHtmlSanitizationService)validationContext + .GetService(typeof(IHtmlSanitizationService)); + + if (sanitizationService is null) + throw new InvalidOperationException( + $"{nameof(IHtmlSanitizationService)} could not be resolved - is it registered in DI?"); + + return sanitizationService.ContainsMarkup(text) + ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName), + MemberNames(validationContext)) + : ValidationResult.Success; + } + + private static IEnumerable MemberNames(ValidationContext validationContext) + { + return validationContext.MemberName is null ? [] : [validationContext.MemberName]; + } +} diff --git a/src/Core/Grand.Infrastructure/Validators/SanitizeHtmlAttribute.cs b/src/Core/Grand.Infrastructure/Validators/SanitizeHtmlAttribute.cs new file mode 100644 index 000000000..42360c83c --- /dev/null +++ b/src/Core/Grand.Infrastructure/Validators/SanitizeHtmlAttribute.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Grand.Infrastructure.Security; + +namespace Grand.Infrastructure.Validators; + +/// +/// Rejects a bound string property when it contains markup outside the rich-text allowlist - a tag, +/// attribute, style rule, or url (script, iframe to an unlisted host, javascript:, entity-encoded scheme, +/// etc.) that can execute or navigate on its own. Detection runs through , +/// a real HTML parser rather than a regular expression, so it is not defeated by whitespace, casing, or +/// encoding tricks the way a pattern match is. +/// is resolved from rather +/// than constructor injection - ASP.NET Core's model validation constructs the ValidationContext with +/// HttpContext.RequestServices, so this is the supported way for a DataAnnotations attribute to reach a +/// scoped/singleton service. +/// +[AttributeUsage(AttributeTargets.Property)] +public class SanitizeHtmlAttribute() : ValidationAttribute("{0} contains HTML markup that is not allowed.") +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value is not string html || string.IsNullOrWhiteSpace(html)) return ValidationResult.Success; + + var sanitizationService = (IHtmlSanitizationService)validationContext + .GetService(typeof(IHtmlSanitizationService)); + + if (sanitizationService is null) + throw new InvalidOperationException( + $"{nameof(IHtmlSanitizationService)} could not be resolved - is it registered in DI?"); + + return sanitizationService.ContainsDisallowedRichText(html) + ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName), + MemberNames(validationContext)) + : ValidationResult.Success; + } + + private static IEnumerable MemberNames(ValidationContext validationContext) + { + return validationContext.MemberName is null ? [] : [validationContext.MemberName]; + } +} diff --git a/src/Modules/Grand.Module.Api/DTOs/Catalog/BrandDto.cs b/src/Modules/Grand.Module.Api/DTOs/Catalog/BrandDto.cs index fd4b4b309..3a9d8057d 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Catalog/BrandDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Catalog/BrandDto.cs @@ -1,4 +1,5 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Catalog; @@ -6,11 +7,16 @@ public class BrandDto : BaseApiEntityModel { public string Name { get; set; } public string SeName { get; set; } + [SanitizeHtml] public string Description { get; set; } + [SanitizeHtml] public string BottomDescription { get; set; } public string BrandLayoutId { get; set; } + [NoHtml] public string MetaKeywords { get; set; } + [NoHtml] public string MetaDescription { get; set; } + [NoHtml] public string MetaTitle { get; set; } public string PictureId { get; set; } public int PageSize { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Catalog/CategoryDTO.cs b/src/Modules/Grand.Module.Api/DTOs/Catalog/CategoryDTO.cs index 354675cd2..f66a0b157 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Catalog/CategoryDTO.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Catalog/CategoryDTO.cs @@ -1,15 +1,21 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Catalog; public class CategoryDto : BaseApiEntityModel { public string Name { get; set; } + [SanitizeHtml] public string Description { get; set; } + [SanitizeHtml] public string BottomDescription { get; set; } public string CategoryLayoutId { get; set; } + [NoHtml] public string MetaKeywords { get; set; } + [NoHtml] public string MetaDescription { get; set; } + [NoHtml] public string MetaTitle { get; set; } public string SeName { get; set; } public string ParentCategoryId { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Catalog/CollectionDto.cs b/src/Modules/Grand.Module.Api/DTOs/Catalog/CollectionDto.cs index 6f80f0721..7a9ae94f7 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Catalog/CollectionDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Catalog/CollectionDto.cs @@ -1,4 +1,5 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Catalog; @@ -6,11 +7,16 @@ public class CollectionDto : BaseApiEntityModel { public string Name { get; set; } public string SeName { get; set; } + [SanitizeHtml] public string Description { get; set; } + [SanitizeHtml] public string BottomDescription { get; set; } public string CollectionLayoutId { get; set; } + [NoHtml] public string MetaKeywords { get; set; } + [NoHtml] public string MetaDescription { get; set; } + [NoHtml] public string MetaTitle { get; set; } public string PictureId { get; set; } public int PageSize { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductAttributeDto.cs b/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductAttributeDto.cs index 413d67032..f9436ca70 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductAttributeDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductAttributeDto.cs @@ -1,10 +1,12 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Catalog; public class ProductAttributeDto : BaseApiEntityModel { public string Name { get; set; } + [SanitizeHtml] public string Description { get; set; } public IList PredefinedProductAttributeValues { get; set; } = diff --git a/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductDto.cs b/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductDto.cs index 7ca07b308..595de72d8 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Catalog/ProductDto.cs @@ -1,4 +1,5 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; using Grand.Domain.Catalog; namespace Grand.Module.Api.DTOs.Catalog; @@ -10,15 +11,21 @@ public class ProductDto : BaseApiEntityModel public bool VisibleIndividually { get; set; } public string Name { get; set; } public string SeName { get; set; } + [SanitizeHtml] public string ShortDescription { get; set; } + [SanitizeHtml] public string FullDescription { get; set; } + [NoHtml] public string AdminComment { get; set; } public string ProductLayoutId { get; set; } public string BrandId { get; set; } public string VendorId { get; set; } public bool ShowOnHomePage { get; set; } + [NoHtml] public string MetaKeywords { get; set; } + [NoHtml] public string MetaDescription { get; set; } + [NoHtml] public string MetaTitle { get; set; } public bool AllowCustomerReviews { get; set; } public int ApprovedRatingSum { get; set; } @@ -44,6 +51,7 @@ public class ProductDto : BaseApiEntityModel public bool HasSampleDownload { get; set; } public string SampleDownloadId { get; set; } public bool HasUserAgreement { get; set; } + [SanitizeHtml] public string UserAgreementText { get; set; } public bool IsRecurring { get; set; } public int RecurringCycleLength { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Customers/CustomerDto.cs b/src/Modules/Grand.Module.Api/DTOs/Customers/CustomerDto.cs index ff312c5c1..6936e161f 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Customers/CustomerDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Customers/CustomerDto.cs @@ -1,4 +1,5 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; using System.ComponentModel.DataAnnotations; namespace Grand.Module.Api.DTOs.Customers; @@ -10,6 +11,7 @@ public class CustomerDto : BaseApiEntityModel [Key] public string Email { get; set; } + [NoHtml] public string AdminComment { get; set; } public bool IsTaxExempt { get; set; } public bool FreeShipping { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Customers/VendorDto.cs b/src/Modules/Grand.Module.Api/DTOs/Customers/VendorDto.cs index 448181bc4..c2be84194 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Customers/VendorDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Customers/VendorDto.cs @@ -1,4 +1,5 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Customers; @@ -8,8 +9,10 @@ public class VendorDto : BaseApiEntityModel public string SeName { get; set; } public string PictureId { get; set; } public string Email { get; set; } + [SanitizeHtml] public string Description { get; set; } public string StoreId { get; set; } + [NoHtml] public string AdminComment { get; set; } public bool Active { get; set; } public bool Deleted { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Shipping/PickupPointDto.cs b/src/Modules/Grand.Module.Api/DTOs/Shipping/PickupPointDto.cs index dbc272e23..c52e22b36 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Shipping/PickupPointDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Shipping/PickupPointDto.cs @@ -1,11 +1,14 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Shipping; public class PickupPointDto : BaseApiEntityModel { public string Name { get; set; } + [SanitizeHtml] public string Description { get; set; } + [NoHtml] public string AdminComment { get; set; } public string WarehouseId { get; set; } public string StoreId { get; set; } diff --git a/src/Modules/Grand.Module.Api/DTOs/Shipping/ShippingMethodDto.cs b/src/Modules/Grand.Module.Api/DTOs/Shipping/ShippingMethodDto.cs index 6df456578..cb68ec09d 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Shipping/ShippingMethodDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Shipping/ShippingMethodDto.cs @@ -1,10 +1,12 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Shipping; public class ShippingMethodDto : BaseApiEntityModel { public string Name { get; set; } + [SanitizeHtml] public string Description { get; set; } public int DisplayOrder { get; set; } } \ No newline at end of file diff --git a/src/Modules/Grand.Module.Api/DTOs/Shipping/WarehouseDto.cs b/src/Modules/Grand.Module.Api/DTOs/Shipping/WarehouseDto.cs index d31e55e43..b52d49240 100644 --- a/src/Modules/Grand.Module.Api/DTOs/Shipping/WarehouseDto.cs +++ b/src/Modules/Grand.Module.Api/DTOs/Shipping/WarehouseDto.cs @@ -1,9 +1,11 @@ -using Grand.Module.Api.Models; +using Grand.Infrastructure.Validators; +using Grand.Module.Api.Models; namespace Grand.Module.Api.DTOs.Shipping; public class WarehouseDto : BaseApiEntityModel { public string Name { get; set; } + [NoHtml] public string AdminComment { get; set; } } \ No newline at end of file diff --git a/src/Tests/Grand.Infrastructure.Tests/Security/HtmlSanitizationServiceTests.cs b/src/Tests/Grand.Infrastructure.Tests/Security/HtmlSanitizationServiceTests.cs new file mode 100644 index 000000000..f62eb043c --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Security/HtmlSanitizationServiceTests.cs @@ -0,0 +1,220 @@ +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Security; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Infrastructure.Tests.Security; + +[TestClass] +public class HtmlSanitizationServiceTests +{ + private HtmlSanitizationService _service; + + [TestInitialize] + public void Init() + { + _service = new HtmlSanitizationService(new SecurityConfig()); + } + + /// + /// Every payload here defeated the regex blacklist that this service replaces. + /// + [TestMethod] + [DataRow("", DisplayName = "script element")] + [DataRow("", DisplayName = "script src across a newline")] + [DataRow("", DisplayName = "script body across newlines")] + [DataRow("", DisplayName = "onerror")] + [DataRow("", DisplayName = "onerror with newline before =")] + [DataRow("", DisplayName = "onerror mixed case and spaced")] + [DataRow("", DisplayName = "svg onload")] + [DataRow("
x
", DisplayName = "onpointerover")] + [DataRow("
x
", DisplayName = "onanimationstart")] + [DataRow("
x
", DisplayName = "ontoggle")] + [DataRow("", DisplayName = "body onload")] + [DataRow("x", DisplayName = "javascript scheme")] + [DataRow("x", DisplayName = "entity-encoded javascript scheme")] + [DataRow("x", DisplayName = "javascript scheme mixed case")] + [DataRow("", DisplayName = "iframe srcdoc")] + [DataRow("", DisplayName = "object")] + [DataRow("", DisplayName = "embed")] + [DataRow("
", DisplayName = "phishing form")] + [DataRow("", DisplayName = "formaction")] + [DataRow("x", + DisplayName = "data:text/html")] + [DataRow("
x
", DisplayName = "css url javascript")] + [DataRow("", DisplayName = "base tag")] + [DataRow("
x
", DisplayName = "vue directives")] + [DataRow("", DisplayName = "iframe from unlisted host")] + [DataRow("", DisplayName = "iframe protocol-relative unlisted host")] + public void ContainsDisallowedRichText_FlagsExecutableMarkup(string payload) + { + Assert.IsTrue(_service.ContainsDisallowedRichText(payload), $"payload was not flagged: {payload}"); + } + + /// + /// Ordinary editor output must not be rejected, even the parts the allowlist reformats (e.g. an implied + /// <tbody> made explicit, or a css declaration re-spaced) - those are not removals. + /// + [TestMethod] + [DataRow("

Hello world

")] + [DataRow("
  • one
  • two
")] + [DataRow("link")] + [DataRow("mail")] + [DataRow("\"product\"")] + [DataRow("

Heading

quote
")] + [DataRow("
cell
")] + [DataRow("

centered

")] + public void ContainsDisallowedRichText_AllowsLegitimateEditorOutput(string html) + { + Assert.IsFalse(_service.ContainsDisallowedRichText(html), $"legitimate markup was flagged: {html}"); + } + + [TestMethod] + public void ContainsDisallowedRichText_AllowsIframeFromAllowedHost() + { + Assert.IsFalse(_service.ContainsDisallowedRichText( + "")); + } + + [TestMethod] + public void ContainsDisallowedRichText_AllowsRelativeIframe() + { + //the file manager inserts self-hosted video as a relative url; it cannot leave this origin + Assert.IsFalse(_service.ContainsDisallowedRichText("")); + } + + [TestMethod] + public void ContainsDisallowedRichText_FlagsEveryIframeWhenHostListIsConfiguredEmpty() + { + var service = new HtmlSanitizationService(new SecurityConfig { SanitizerAllowedIframeHosts = [] }); + + Assert.IsTrue(service.ContainsDisallowedRichText( + "")); + } + + [TestMethod] + public void ContainsDisallowedRichText_HonoursConfiguredHosts() + { + var service = new HtmlSanitizationService(new SecurityConfig { + SanitizerAllowedIframeHosts = ["*.trusted.tld"] + }); + + Assert.IsFalse(service.ContainsDisallowedRichText("")); + Assert.IsTrue(service.ContainsDisallowedRichText("")); + } + + [TestMethod] + public void ContainsDisallowedRichText_AllowsInlineImageButNotInlineDocument() + { + Assert.IsFalse(_service.ContainsDisallowedRichText("")); + Assert.IsTrue(_service.ContainsDisallowedRichText( + "alert(1)\">x")); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ContainsDisallowedRichText_TreatsEmptyInputAsClean(string value) + { + Assert.IsFalse(_service.ContainsDisallowedRichText(value)); + } + + [TestMethod] + public void ContainsMarkup_FlagsAnyTag() + { + Assert.IsTrue(_service.ContainsMarkup("Bold title")); + Assert.IsTrue(_service.ContainsMarkup("")); + Assert.IsTrue(_service.ContainsMarkup("plain
text")); + } + + [TestMethod] + public void ContainsMarkup_AllowsPlainText() + { + Assert.IsFalse(_service.ContainsMarkup("Just a title")); + Assert.IsFalse(_service.ContainsMarkup("Tea & Coffee")); + Assert.IsFalse(_service.ContainsMarkup("Price < 10 and > 2")); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public void ContainsMarkup_TreatsEmptyInputAsClean(string value) + { + Assert.IsFalse(_service.ContainsMarkup(value)); + } + + /// + /// The per-call detection flag must not leak between unrelated calls on the same thread. + /// + [TestMethod] + public void ContainsDisallowedRichText_DoesNotLeakStateBetweenCalls() + { + Assert.IsTrue(_service.ContainsDisallowedRichText("")); + Assert.IsFalse(_service.ContainsDisallowedRichText("

clean

")); + Assert.IsTrue(_service.ContainsDisallowedRichText("")); + } + + /// + /// The singleton holds no mutable shared state - each call builds its own HtmlSanitizer around read-only + /// allowlists, with a result flag captured in a local closure - so concurrent calls on different threads + /// must never see each other's result, without needing a lock. Runs enough iterations across enough + /// threads that the old [ThreadStatic]-without-serialization design (and a naive shared mutable-field + /// design) would reliably produce a wrong verdict here. + /// + [TestMethod] + public void ContainsDisallowedRichText_IsCorrectUnderConcurrentCalls() + { + const string dangerous = ""; + const string clean = "

clean

"; + + var wrongVerdicts = 0; + Parallel.For(0, 2000, i => + { + var service = _service; // shared singleton instance, as it is registered in DI + if (i % 2 == 0) + { + if (!service.ContainsDisallowedRichText(dangerous)) Interlocked.Increment(ref wrongVerdicts); + } + else + { + if (service.ContainsDisallowedRichText(clean)) Interlocked.Increment(ref wrongVerdicts); + } + }); + + Assert.AreEqual(0, wrongVerdicts, "a concurrent call observed another call's detection result"); + } + + /// + /// SecurityConfig.EnableHtmlSanitization is an operational kill switch: false must make both detection + /// methods report "nothing disallowed" even for payloads that are otherwise always flagged. + /// + [TestMethod] + [DataRow("")] + [DataRow("")] + public void ContainsDisallowedRichText_AcceptsEverything_WhenSanitizationDisabled(string payload) + { + var service = new HtmlSanitizationService(new SecurityConfig { EnableHtmlSanitization = false }); + + Assert.IsFalse(service.ContainsDisallowedRichText(payload)); + } + + [TestMethod] + [DataRow("")] + [DataRow("bold")] + public void ContainsMarkup_AcceptsEverything_WhenSanitizationDisabled(string payload) + { + var service = new HtmlSanitizationService(new SecurityConfig { EnableHtmlSanitization = false }); + + Assert.IsFalse(service.ContainsMarkup(payload)); + } + + [TestMethod] + public void EnableHtmlSanitization_DefaultsToTrue_WhenNotSetExplicitly() + { + // mirrors what configuration binding leaves behind when the appsettings.json key is absent + var service = new HtmlSanitizationService(new SecurityConfig()); + + Assert.IsTrue(service.ContainsDisallowedRichText("")); + } +} diff --git a/src/Tests/Grand.Infrastructure.Tests/Validators/SanitizeHtmlAttributeTests.cs b/src/Tests/Grand.Infrastructure.Tests/Validators/SanitizeHtmlAttributeTests.cs new file mode 100644 index 000000000..bf84ce980 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Validators/SanitizeHtmlAttributeTests.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Security; +using Grand.Infrastructure.Validators; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Infrastructure.Tests.Validators; + +/// +/// Exercises SanitizeHtmlAttribute/NoHtmlAttribute the way ASP.NET Core's model validation exercises any +/// ValidationAttribute: through ValidationContext.GetService, backed by a real service provider - not through +/// a live MVC pipeline (that path was covered manually against a running app earlier in this change). +/// +[TestClass] +public class SanitizeHtmlAttributeTests +{ + private IServiceProvider _serviceProvider; + + [TestInitialize] + public void Init() + { + var services = new ServiceCollection(); + services.AddSingleton(new HtmlSanitizationService(new SecurityConfig())); + _serviceProvider = services.BuildServiceProvider(); + } + + [TestMethod] + public void SanitizeHtml_AcceptsCleanMarkup() + { + var model = new SanitizeHtmlSourceTest { FullDescription = "

Hello world

" }; + + var results = Validate(model); + + Assert.AreEqual(0, results.Count); + } + + [TestMethod] + public void SanitizeHtml_RejectsExecutableMarkup() + { + var model = new SanitizeHtmlSourceTest { FullDescription = "" }; + + var results = Validate(model); + + Assert.AreEqual(1, results.Count); + StringAssert.Contains(results[0].MemberNames.FirstOrDefault() ?? "", "FullDescription"); + } + + [TestMethod] + public void SanitizeHtml_AcceptsNullOrEmpty() + { + Assert.AreEqual(0, Validate(new SanitizeHtmlSourceTest { FullDescription = null }).Count); + Assert.AreEqual(0, Validate(new SanitizeHtmlSourceTest { FullDescription = "" }).Count); + } + + [TestMethod] + public void NoHtml_AcceptsPlainText() + { + var model = new SanitizeHtmlSourceTest { MetaTitle = "Shoes & Boots" }; + + Assert.AreEqual(0, Validate(model).Count); + } + + [TestMethod] + public void NoHtml_RejectsAnyMarkup() + { + var model = new SanitizeHtmlSourceTest { MetaTitle = "Shoes" }; + + var results = Validate(model); + + Assert.AreEqual(1, results.Count); + StringAssert.Contains(results[0].MemberNames.FirstOrDefault() ?? "", "MetaTitle"); + } + + [TestMethod] + public void ThrowsWhenSanitizationServiceIsNotRegistered() + { + var emptyProvider = new ServiceCollection().BuildServiceProvider(); + var model = new SanitizeHtmlSourceTest { FullDescription = "

x

" }; + var context = new ValidationContext(model, emptyProvider, null) { MemberName = nameof(model.FullDescription) }; + + Assert.ThrowsExactly(() => + new SanitizeHtmlAttribute().GetValidationResult(model.FullDescription, context)); + } + + private List Validate(SanitizeHtmlSourceTest model) + { + var results = new List(); + Validator.TryValidateObject(model, + new ValidationContext(model, _serviceProvider, null), results, validateAllProperties: true); + return results; + } +} + +public class SanitizeHtmlSourceTest +{ + [SanitizeHtml] public string FullDescription { get; set; } + + [NoHtml] public string MetaTitle { get; set; } +} diff --git a/src/Web/Grand.Web.Admin/App_Data/appsettings.json b/src/Web/Grand.Web.Admin/App_Data/appsettings.json index ffb1a975d..abc2b81cc 100644 --- a/src/Web/Grand.Web.Admin/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Admin/App_Data/appsettings.json @@ -78,7 +78,15 @@ "CookieSameSite": "Lax", "CookieSameSiteExternalAuth": "None", //Enabling this setting allows for verification of access to a specific controller and action in the admin panel using menu configuration. - "AuthorizeAdminMenu": false + "AuthorizeAdminMenu": false, + //Hosts whose iframes survive sanitization of rich-text content (product descriptions, blog posts, pages). + //An iframe pointing anywhere else is removed, because its src is otherwise attacker-controlled. A leading "*." matches any subdomain. + //Remove the key entirely to use the built-in video-embed defaults (youtube, youtube-nocookie, vimeo, google); set it to [] to block every iframe. + "SanitizerAllowedIframeHosts": [ "youtube.com", "*.youtube.com", "youtube-nocookie.com", "*.youtube-nocookie.com", "vimeo.com", "*.vimeo.com", "google.com", "*.google.com" ], + //Operational escape hatch, not a security setting: when [SanitizeHtml]/[NoHtml] wrongly reject legitimate + //content in production, set this to false to accept input unsanitized while a fix is prepared, then set it + //back to true as soon as possible. Default true. + "EnableHtmlSanitization": true }, "Cache": { //Gets or sets a value indicating for default cache time in minutes" diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml index 790794e0d..975ddf4dd 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml @@ -1,4 +1,5 @@ -@model DiscountModel +@using System.Text.Encodings.Web +@model DiscountModel @{ @@ -100,7 +101,7 @@ @for (var i = 0; i < Model.DiscountRequirementMetaInfos.Count; i++) { var drmi = Model.DiscountRequirementMetaInfos[i]; - { discountRequirementId: "@(drmi.DiscountRequirementId)", ruleName: "@(drmi.RuleName)", url: "@(Html.Raw(drmi.ConfigurationUrl))" } + { discountRequirementId: "@(drmi.DiscountRequirementId)", ruleName: "@(drmi.RuleName)", url: "@(Html.Raw(JavaScriptEncoder.Default.Encode(drmi.ConfigurationUrl)))" } if (i != Model.DiscountRequirementMetaInfos.Count - 1) { , diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Partials/CreateOrUpdate.TabResources.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Partials/CreateOrUpdate.TabResources.cshtml index 1efa53a47..31525fac8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Partials/CreateOrUpdate.TabResources.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Partials/CreateOrUpdate.TabResources.cshtml @@ -1,4 +1,5 @@ -@using Grand.Domain.Localization +@using System.Text.Encodings.Web +@using Grand.Domain.Localization @model LanguageModel @inject AdminAreaSettings adminAreaSettings @@ -50,7 +51,7 @@ { Id: '@(method.Value)', - Name: "@Html.Raw(method.Text)" + Name: "@Html.Raw(JavaScriptEncoder.Default.Encode(method.Text))" }, } diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml index a60287c10..12a09140c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml @@ -1,4 +1,5 @@ -@model BulkEditListModel +@using System.Text.Encodings.Web +@model BulkEditListModel @inject AdminAreaSettings adminAreaSettings @{ //page title @@ -95,7 +96,7 @@ { Id: '@(method.Value)', - Name: "@Html.Raw(method.Text)" + Name: "@Html.Raw(JavaScriptEncoder.Default.Encode(method.Text))" }, } diff --git a/src/Web/Grand.Web.AdminShared/Models/Blogs/BlogPostModel.cs b/src/Web/Grand.Web.AdminShared/Models/Blogs/BlogPostModel.cs index f416d7700..27a27a805 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Blogs/BlogPostModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Blogs/BlogPostModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Link; using Grand.Web.Common.Models; using System.ComponentModel.DataAnnotations; @@ -16,11 +17,11 @@ public class BlogPostModel : BaseEntityModel, ILocalizedModel AvailableBrandLayouts { get; set; } = []; - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaTitle")] public string MetaTitle { get; set; } @@ -105,23 +105,23 @@ public class BrandLocalizedModel : ILocalizedModelLocal, ISlugModelLocal [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.Name")] public string Name { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.Description")] public string Description { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.BottomDescription")] public string BottomDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Brands.Fields.MetaTitle")] public string MetaTitle { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Catalog/CategoryModel.cs b/src/Web/Grand.Web.AdminShared/Models/Catalog/CategoryModel.cs index 51faf9e70..698a5ec5c 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Catalog/CategoryModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Catalog/CategoryModel.cs @@ -3,7 +3,7 @@ using Grand.Web.AdminShared.Models.Discounts; using Grand.Web.Common.Link; using Grand.Web.Common.Models; -using Grand.Web.Common.Validators; +using Grand.Infrastructure.Validators; using Microsoft.AspNetCore.Mvc.Rendering; using System.ComponentModel.DataAnnotations; @@ -22,11 +22,11 @@ public CategoryModel() [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.Name")] public string Name { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.Description")] public string Description { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.BottomDescription")] public string BottomDescription { get; set; } @@ -35,15 +35,15 @@ public CategoryModel() public IList AvailableCategoryLayouts { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaTitle")] public string MetaTitle { get; set; } @@ -191,23 +191,23 @@ public class CategoryLocalizedModel : ILocalizedModelLocal, ISlugModelLocal [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.Name")] public string Name { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.Description")] public string Description { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.BottomDescription")] public string BottomDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Categories.Fields.MetaTitle")] public string MetaTitle { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Catalog/CollectionModel.cs b/src/Web/Grand.Web.AdminShared/Models/Catalog/CollectionModel.cs index bfee5ed70..5e6d9303f 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Catalog/CollectionModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Catalog/CollectionModel.cs @@ -3,7 +3,7 @@ using Grand.Web.AdminShared.Models.Discounts; using Grand.Web.Common.Link; using Grand.Web.Common.Models; -using Grand.Web.Common.Validators; +using Grand.Infrastructure.Validators; using Microsoft.AspNetCore.Mvc.Rendering; using System.ComponentModel.DataAnnotations; @@ -23,11 +23,11 @@ public CollectionModel() [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.Name")] public string Name { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.Description")] public string Description { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.BottomDescription")] public string BottomDescription { get; set; } @@ -36,15 +36,15 @@ public CollectionModel() public IList AvailableCollectionLayouts { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaTitle")] public string MetaTitle { get; set; } @@ -169,23 +169,23 @@ public class CollectionLocalizedModel : ILocalizedModelLocal, ISlugModelLocal [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.Name")] public string Name { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.Description")] public string Description { get; set; } - [NoScripts] + [SanitizeHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.BottomDescription")] public string BottomDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaKeywords")] public string MetaKeywords { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaDescription")] public string MetaDescription { get; set; } - [NoScripts] + [NoHtml] [GrandResourceDisplayName("Admin.Catalog.Collections.Fields.MetaTitle")] public string MetaTitle { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductAttributeModel.cs b/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductAttributeModel.cs index b27fb0d90..a3d7f7803 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductAttributeModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductAttributeModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Link; using Grand.Web.Common.Models; using System.ComponentModel.DataAnnotations; @@ -14,6 +15,7 @@ public class ProductAttributeModel : BaseEntityModel, ILocalizedModel AvailableLevels { get; set; } = new List(); + [NoHtml] [GrandResourceDisplayName("Admin.Courses.Course.Fields.MetaKeywords")] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.SeName")] @@ -114,19 +117,19 @@ public class CourseLocalizedModel : ILocalizedModelLocal, ISlugModelLocal public string ShortDescription { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.Description")] - + [SanitizeHtml] public string Description { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.MetaKeywords")] - + [NoHtml] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Courses.Course.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } public string LanguageId { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs index d866dce47..1c794ca16 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs @@ -2,6 +2,7 @@ using Grand.Domain.Common; using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Binders; using Grand.Web.Common.Models; using Microsoft.AspNetCore.Mvc; @@ -193,6 +194,7 @@ public class CustomerModel : BaseEntityModel public int AddLoyaltyPointsValue { get; set; } [GrandResourceDisplayName("Admin.Customers.Customers.LoyaltyPoints.Fields.AddLoyaltyPointsMessage")] + [NoHtml] public string AddLoyaltyPointsMessage { get; set; } [GrandResourceDisplayName("Admin.Customers.Customers.LoyaltyPoints.Fields.AddLoyaltyPointsStore")] @@ -272,6 +274,7 @@ public class SendEmailModel : BaseModel public string Subject { get; set; } [GrandResourceDisplayName("Admin.Customers.Customers.SendEmail.Body")] + [NoHtml] public string Body { get; set; } [GrandResourceDisplayName("Admin.Customers.Customers.SendEmail.SendImmediately")] diff --git a/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentModel.cs b/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentModel.cs index fe49ed56c..66c202875 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Link; using Microsoft.AspNetCore.Mvc.Rendering; using System.ComponentModel.DataAnnotations; @@ -19,6 +20,7 @@ public class DocumentModel : BaseEntityModel, IGroupLinkModel, IStoreLinkModel public IList AvailableSelesEmployees { get; set; } = new List(); + [SanitizeHtml] [GrandResourceDisplayName("Admin.Documents.Document.Fields.Description")] public string Description { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentTypeModel.cs b/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentTypeModel.cs index 4e09735fa..a6e644bd3 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentTypeModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Documents/DocumentTypeModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; namespace Grand.Web.AdminShared.Models.Documents; @@ -10,7 +11,7 @@ public class DocumentTypeModel : BaseEntityModel public string Name { get; set; } [GrandResourceDisplayName("Admin.Documents.Type.Fields.Description")] - + [SanitizeHtml] public string Description { get; set; } [GrandResourceDisplayName("Admin.Documents.Type.Fields.DisplayOrder")] diff --git a/src/Web/Grand.Web.AdminShared/Models/Knowledgebase/KnowledgebaseArticleModel.cs b/src/Web/Grand.Web.AdminShared/Models/Knowledgebase/KnowledgebaseArticleModel.cs index b03db2ec7..d57229a3d 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Knowledgebase/KnowledgebaseArticleModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Knowledgebase/KnowledgebaseArticleModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Link; using Grand.Web.Common.Models; using Microsoft.AspNetCore.Mvc.Rendering; @@ -13,6 +14,7 @@ public class KnowledgebaseArticleModel : BaseEntityModel, ILocalizedModel Categories { get; set; } = new(); + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaKeywords")] public string MetaKeywords { get; set; } + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaDescription")] public string MetaDescription { get; set; } + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaTitle")] public string MetaTitle { get; set; } @@ -59,15 +64,19 @@ public class KnowledgebaseCategoryLocalizedModel : ILocalizedModelLocal, ISlugMo [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.Name")] public string Name { get; set; } + [SanitizeHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.Description")] public string Description { get; set; } + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaKeywords")] public string MetaKeywords { get; set; } + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaDescription")] public string MetaDescription { get; set; } + [NoHtml] [GrandResourceDisplayName("Admin.Content.Knowledgebase.KnowledgebaseCategory.Fields.MetaTitle")] public string MetaTitle { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Messages/NewsletterCategoryModel.cs b/src/Web/Grand.Web.AdminShared/Models/Messages/NewsletterCategoryModel.cs index e58ef258e..1bd85f438 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Messages/NewsletterCategoryModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Messages/NewsletterCategoryModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Link; using Grand.Web.Common.Models; using System.ComponentModel.DataAnnotations; @@ -14,7 +15,7 @@ public class NewsletterCategoryModel : BaseEntityModel, ILocalizedModel, I public string Title { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.Body")] - + [SanitizeHtml] public string Body { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.PageLayout")] @@ -61,15 +62,15 @@ public class PageModel : BaseEntityModel, ILocalizedModel, I public IList AvailablePageLayouts { get; set; } = new List(); [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaKeywords")] - + [NoHtml] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.SeName")] @@ -104,19 +105,19 @@ public class PageLocalizedModel : ILocalizedModelLocal, ISlugModelLocal public string Title { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.Body")] - + [SanitizeHtml] public string Body { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaKeywords")] - + [NoHtml] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Content.Pages.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } public string LanguageId { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Shipping/PickupPointModel.cs b/src/Web/Grand.Web.AdminShared/Models/Shipping/PickupPointModel.cs index 2ef2d8501..7220cbcb4 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Shipping/PickupPointModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Shipping/PickupPointModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.AdminShared.Models.Common; using Microsoft.AspNetCore.Mvc.Rendering; @@ -12,11 +13,11 @@ public class PickupPointModel : BaseEntityModel public string Name { get; set; } [GrandResourceDisplayName("Admin.Configuration.Shipping.PickupPoint.Fields.Description")] - + [NoHtml] public string Description { get; set; } [GrandResourceDisplayName("Admin.Configuration.Shipping.PickupPoint.Fields.AdminComment")] - + [NoHtml] public string AdminComment { get; set; } [GrandResourceDisplayName("Admin.Configuration.Shipping.PickupPoint.Fields.Address")] diff --git a/src/Web/Grand.Web.AdminShared/Models/Shipping/ShippingMethodModel.cs b/src/Web/Grand.Web.AdminShared/Models/Shipping/ShippingMethodModel.cs index b6b994647..2d7da6570 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Shipping/ShippingMethodModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Shipping/ShippingMethodModel.cs @@ -1,5 +1,6 @@ -using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Models; using Microsoft.AspNetCore.Mvc.Rendering; @@ -12,7 +13,7 @@ public class ShippingMethodModel : BaseEntityModel, ILocalizedModel AvailableStores { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.AdminComment")] - + [NoHtml] public string AdminComment { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.Active")] @@ -54,15 +55,15 @@ public VendorModel() public bool AllowCustomerReviews { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaKeywords")] - + [NoHtml] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.SeName")] @@ -125,19 +126,19 @@ public class VendorLocalizedModel : ILocalizedModelLocal, ISlugModelLocal public string Name { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.Description")] - + [SanitizeHtml] public string Description { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaKeywords")] - + [NoHtml] public string MetaKeywords { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaDescription")] - + [NoHtml] public string MetaDescription { get; set; } [GrandResourceDisplayName("Admin.Vendors.Fields.MetaTitle")] - + [NoHtml] public string MetaTitle { get; set; } public string LanguageId { get; set; } diff --git a/src/Web/Grand.Web.Common/Validators/NoScriptsAttribute.cs b/src/Web/Grand.Web.Common/Validators/NoScriptsAttribute.cs deleted file mode 100644 index 1ab398b32..000000000 --- a/src/Web/Grand.Web.Common/Validators/NoScriptsAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; - -namespace Grand.Web.Common.Validators; - -public class NoScriptsAttribute : ValidationAttribute -{ - // Simple regular expression to detect potential scripts - private const string Pattern = - ".*?|javascript:[^\\s]*|onload=|onerror=|onmouseover=|onclick=|onchange=|onsubmit="; - - protected override ValidationResult IsValid(object value, ValidationContext validationContext) - { - if (value == null) return ValidationResult.Success; - var valueAsString = value.ToString(); - // Check if the value contains a script - return ContainsScript(valueAsString) - ? new ValidationResult("JavaScript scripts are not allowed.") - : ValidationResult.Success; - } - - private static bool ContainsScript(string input) - { - var scriptRegex = new Regex(Pattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); - return scriptRegex.IsMatch(input); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/App_Data/appsettings.json b/src/Web/Grand.Web.Store/App_Data/appsettings.json index 8de0a2c9c..7d37b6440 100644 --- a/src/Web/Grand.Web.Store/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Store/App_Data/appsettings.json @@ -78,7 +78,15 @@ "CookieSameSite": "Lax", "CookieSameSiteExternalAuth": "None", //Enabling this setting allows for verification of access to a specific controller and action in the admin panel using menu configuration. - "AuthorizeAdminMenu": false + "AuthorizeAdminMenu": false, + //Hosts whose iframes survive sanitization of rich-text content (product descriptions, blog posts, pages). + //An iframe pointing anywhere else is removed, because its src is otherwise attacker-controlled. A leading "*." matches any subdomain. + //Remove the key entirely to use the built-in video-embed defaults (youtube, youtube-nocookie, vimeo, google); set it to [] to block every iframe. + "SanitizerAllowedIframeHosts": [ "youtube.com", "*.youtube.com", "youtube-nocookie.com", "*.youtube-nocookie.com", "vimeo.com", "*.vimeo.com", "google.com", "*.google.com" ], + //Operational escape hatch, not a security setting: when [SanitizeHtml]/[NoHtml] wrongly reject legitimate + //content in production, set this to false to accept input unsanitized while a fix is prepared, then set it + //back to true as soon as possible. Default true. + "EnableHtmlSanitization": true }, "Cache": { //Gets or sets a value indicating for default cache time in minutes" diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Preview.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Preview.cshtml index 7181f83b4..bf79f4933 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Preview.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Preview.cshtml @@ -1,4 +1,4 @@ -@model BlogPostModel +@model BlogPostModel @{ //page title ViewBag.Title = Loc["Admin.Content.Blog.BlogPosts.Preview"]; diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml index fe615efa4..4b0588aef 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/CreateOrUpdate.TabRequirements.cshtml @@ -1,3 +1,4 @@ +@using System.Text.Encodings.Web @model DiscountModel @{ @@ -99,7 +100,7 @@ @for (var i = 0; i < Model.DiscountRequirementMetaInfos.Count; i++) { var drmi = Model.DiscountRequirementMetaInfos[i]; - { discountRequirementId: "@(drmi.DiscountRequirementId)", ruleName: "@(drmi.RuleName)", url: "@(Html.Raw(drmi.ConfigurationUrl))" } + { discountRequirementId: "@(drmi.DiscountRequirementId)", ruleName: "@(drmi.RuleName)", url: "@(Html.Raw(JavaScriptEncoder.Default.Encode(drmi.ConfigurationUrl)))" } if (i != Model.DiscountRequirementMetaInfos.Count - 1) { , diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml index 38ae21002..1a586280c 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml @@ -1,4 +1,5 @@ -@model BulkEditListModel +@using System.Text.Encodings.Web +@model BulkEditListModel @inject AdminAreaSettings adminAreaSettings @{ //page title @@ -89,7 +90,7 @@ { Id: '@(method.Value)', - Name: "@Html.Raw(method.Text)" + Name: "@Html.Raw(JavaScriptEncoder.Default.Encode(method.Text))" }, } diff --git a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json index ffb1a975d..abc2b81cc 100644 --- a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json @@ -78,7 +78,15 @@ "CookieSameSite": "Lax", "CookieSameSiteExternalAuth": "None", //Enabling this setting allows for verification of access to a specific controller and action in the admin panel using menu configuration. - "AuthorizeAdminMenu": false + "AuthorizeAdminMenu": false, + //Hosts whose iframes survive sanitization of rich-text content (product descriptions, blog posts, pages). + //An iframe pointing anywhere else is removed, because its src is otherwise attacker-controlled. A leading "*." matches any subdomain. + //Remove the key entirely to use the built-in video-embed defaults (youtube, youtube-nocookie, vimeo, google); set it to [] to block every iframe. + "SanitizerAllowedIframeHosts": [ "youtube.com", "*.youtube.com", "youtube-nocookie.com", "*.youtube-nocookie.com", "vimeo.com", "*.vimeo.com", "google.com", "*.google.com" ], + //Operational escape hatch, not a security setting: when [SanitizeHtml]/[NoHtml] wrongly reject legitimate + //content in production, set this to false to accept input unsanitized while a fix is prepared, then set it + //back to true as soon as possible. Default true. + "EnableHtmlSanitization": true }, "Cache": { //Gets or sets a value indicating for default cache time in minutes" diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml index 66f0ee2a6..307256c14 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml @@ -1,4 +1,5 @@ -@model BulkEditListModel +@using System.Text.Encodings.Web +@model BulkEditListModel @inject AdminAreaSettings adminAreaSettings @{ //page title @@ -89,7 +90,7 @@ { Id: '@(method.Value)', - Name: "@Html.Raw(method.Text)" + Name: "@Html.Raw(JavaScriptEncoder.Default.Encode(method.Text))" }, } diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs index a809e6de8..19ad1555b 100644 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs +++ b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Models; namespace Grand.Web.Vendor.Models.Catalog; @@ -11,7 +12,7 @@ public class ProductAttributeLocalizedModel : ILocalizedModelLocal public string Name { get; set; } [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.Fields.Description")] - + [SanitizeHtml] public string Description { get; set; } public string LanguageId { get; set; } diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs index 9eae35c5f..4f87d65ee 100644 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs +++ b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs @@ -2,7 +2,7 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; using Grand.Web.Common.Models; -using Grand.Web.Common.Validators; +using Grand.Infrastructure.Validators; using Microsoft.AspNetCore.Mvc.Rendering; using System.ComponentModel.DataAnnotations; @@ -42,18 +42,18 @@ public class ProductModel : BaseEntityModel, ILocalizedModel Items { get; set; } = new List(); diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs b/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs index f151cda35..837a37be7 100644 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs +++ b/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; namespace Grand.Web.Vendor.Models.Shipment; @@ -36,6 +37,7 @@ public class ShipmentModel : BaseEntityModel public bool CanDeliver { get; set; } public DateTime? DeliveryDateUtc { get; set; } + [NoHtml] [GrandResourceDisplayName("Vendor.Orders.Shipments.AdminComment")] public string AdminComment { get; set; } @@ -48,7 +50,7 @@ public class ShipmentModel : BaseEntityModel public bool AddShipmentNoteDisplayToCustomer { get; set; } [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.Note")] - + [NoHtml] public string AddShipmentNoteMessage { get; set; } diff --git a/src/Web/Grand.Web.Vendor/Models/Vendor/VendorModel.cs b/src/Web/Grand.Web.Vendor/Models/Vendor/VendorModel.cs index 08bd4c99c..6f9bb2c57 100644 --- a/src/Web/Grand.Web.Vendor/Models/Vendor/VendorModel.cs +++ b/src/Web/Grand.Web.Vendor/Models/Vendor/VendorModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; using Grand.Web.Common.Models; using Grand.Web.Vendor.Models.Common; @@ -16,19 +17,19 @@ public class VendorModel : BaseEntityModel, ILocalizedModel