fix(catalog): decode and strip stray markup from product descriptions
P0-5: some catalog listings (live backend data, proxied third-party marketplace via novo.market) carry HTML-entity-encoded markup in their description field, e.g. '<attention>...</attention>' and '"AppStops"' — rendered verbatim as visible text on search-result cards and the PDP description tab. Added a pure cleanDescription() util (item.utils.ts) that decodes the common HTML entities and strips any resulting tag-like markup, then wired it into ProductCardComponent (covers Home/Catalog/Search/ Wishlist/Compare/PDP-similar) and ProductDescriptionComponent (PDP description tab). Output stays a plain string rendered via text interpolation (never innerHTML), so this only cleans up display — it introduces no HTML-rendering/XSS surface. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@
|
|||||||
<h3 class="product-name">{{ title || item.name }}</h3>
|
<h3 class="product-name">{{ title || item.name }}</h3>
|
||||||
|
|
||||||
@if (showDescription && description) {
|
@if (showDescription && description) {
|
||||||
<p class="product-description">{{ description }}</p>
|
<p class="product-description">{{ cleanDescription(description) }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showRating) {
|
@if (showRating) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { RouterLink } from '@angular/router';
|
|||||||
import { Product } from '../../core/products/models/product-domain.model';
|
import { Product } from '../../core/products/models/product-domain.model';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
||||||
|
|
||||||
const STOCK_LABEL_KEYS: Record<string, string> = {
|
const STOCK_LABEL_KEYS: Record<string, string> = {
|
||||||
high: 'catalog.stockHigh',
|
high: 'catalog.stockHigh',
|
||||||
@@ -55,6 +55,7 @@ export class ProductCardComponent {
|
|||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getBadgeClass = getBadgeClass;
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
readonly cleanDescription = cleanDescription;
|
||||||
|
|
||||||
onAddToCart(event: Event): void {
|
onAddToCart(event: Event): void {
|
||||||
this.addToCart.emit({ itemID: this.item.itemID, event });
|
this.addToCart.emit({ itemID: this.item.itemID, event });
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
||||||
|
|
||||||
@if (simpleDescription) {
|
@if (simpleDescription) {
|
||||||
<p>{{ simpleDescription }}</p>
|
<p>{{ cleanDescription(simpleDescription) }}</p>
|
||||||
} @else if (product.description) {
|
} @else if (product.description) {
|
||||||
<p>{{ product.description }}</p>
|
<p>{{ cleanDescription(product.description) }}</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { cleanDescription } from '../../../../../utils/item.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-description',
|
selector: 'app-product-description',
|
||||||
@@ -13,4 +14,6 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
export class ProductDescriptionComponent {
|
export class ProductDescriptionComponent {
|
||||||
@Input({ required: true }) product!: Product;
|
@Input({ required: true }) product!: Product;
|
||||||
@Input() simpleDescription = '';
|
@Input() simpleDescription = '';
|
||||||
|
|
||||||
|
readonly cleanDescription = cleanDescription;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,30 @@ export function getBadgeClass(badge: string): string {
|
|||||||
return map[badge.toLowerCase()] || 'badge-custom';
|
return map[badge.toLowerCase()] || 'badge-custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_ENTITY_MAP: Record<string, string> = {
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
' ': ' ',
|
||||||
|
'&': '&',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up seller-supplied description text for safe plain-text display: decodes
|
||||||
|
* HTML entities (some catalog listings arrive HTML-escaped, e.g. "<attention>")
|
||||||
|
* and strips any resulting tag-like markup. Never renders as HTML — output stays a
|
||||||
|
* plain string for text interpolation, so this cannot introduce an XSS surface.
|
||||||
|
*/
|
||||||
|
export function cleanDescription(text: string | undefined | null): string {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const decoded = text.replace(/<|>|"|'|'| |&/g, match => HTML_ENTITY_MAP[match]);
|
||||||
|
return decoded.replace(/<\/?[a-zA-Z][^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the translated name/description for the current language.
|
* Get the translated name/description for the current language.
|
||||||
* Checks translations map first, then names[]/descriptions[] arrays,
|
* Checks translations map first, then names[]/descriptions[] arrays,
|
||||||
|
|||||||
Reference in New Issue
Block a user