㊙️ Security Fixes

- XSS fix: Created SanitizeHtml.ts utility using DOMPurify (already in package.json but never used). Wrapped all 21 dangerouslySetInnerHTML calls in catalog views with SanitizeHtml() — only allows safe tags (b, i, u, br, span, div, p, a, strong, em, img)

- Race condition fix: Added 10-second timeout fallbacks on purchase flags in CatalogPurchaseWidgetView and CatalogGiftView so the flag auto-resets even if the server never responds
This commit is contained in:
DuckieTM
2026-03-23 22:14:03 +01:00
parent dc678cb7ff
commit 7ffb213ce7
22 changed files with 54 additions and 34 deletions
+10
View File
@@ -0,0 +1,10 @@
import DOMPurify from 'dompurify';
export const SanitizeHtml = (html: string): string =>
{
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [ 'b', 'i', 'u', 'br', 'span', 'div', 'p', 'a', 'strong', 'em', 'img' ],
ALLOWED_ATTR: [ 'href', 'target', 'class', 'style', 'src', 'alt' ],
ALLOW_DATA_ATTR: false
});
};
+1
View File
@@ -15,6 +15,7 @@ export * from './PrefixUtils';
export * from './ProductImageUtility';
export * from './Randomizer';
export * from './RoomChatFormatter';
export * from './SanitizeHtml';
export * from './SetLocalStorage';
export * from './SoundNames';
export * from './WindowSaveOptions';
+1 -1
View File
@@ -228,7 +228,7 @@ const CatalogModernViewInner: FC<{}> = () =>
<FaStar className="text-[9px] text-primary shrink-0" />
{ activeNodes && activeNodes.length > 0
? activeNodes.map((node, i) => (
<span key={ node.pageId } className="flex items-center gap-1 min-w-0">
<span key={ `${ node.pageId }-${ i }` } className="flex items-center gap-1 min-w-0">
{ i > 0 && <span className="text-[8px] opacity-30"></span> }
<span className={ `truncate ${ i === activeNodes.length - 1 ? 'font-bold text-dark' : 'cursor-pointer hover:text-primary' }` }
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }>
@@ -128,6 +128,7 @@ export const CatalogGiftView: FC<{}> = props =>
if(isBuyingGift) return;
isBuyingGift = true;
setTimeout(() => { isBuyingGift = false; }, 10000);
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
@@ -1,5 +1,5 @@
import { FC } from 'react';
import { LocalizeText } from '../../../../../api';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
@@ -31,7 +31,7 @@ export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -1,7 +1,7 @@
import { ColorConverter } from '@nitrots/nitro-renderer';
import { FC, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { IPurchasableOffer } from '../../../../../api';
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
@@ -146,7 +146,7 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -1,6 +1,6 @@
import { PurchasePrefixComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api';
import { LocalizeText, SanitizeHtml, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
@@ -137,7 +137,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
{ page.localization.getImage(0) &&
<img alt="" className="w-full rounded" src={ page.localization.getImage(0) } /> }
{ page.localization.getText(0) &&
<div className="text-sm mb-1" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> }
<div className="text-sm mb-1" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
{ /* Live Preview */ }
<div className="relative flex items-center justify-center p-4 rounded-lg min-h-[56px]"
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { FaEdit, FaPlus } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum } from '../../../../../api';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
@@ -90,7 +90,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
<div className="flex items-center gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ !!page.localization.getImage(1) &&
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div> }
{ /* Item grid */ }
@@ -1,4 +1,5 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
@@ -23,7 +24,7 @@ export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -1,6 +1,6 @@
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../../api';
import { SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
@@ -26,7 +26,7 @@ export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!currentOffer &&
@@ -1,6 +1,6 @@
import { CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { LocalizeText } from '../../../../../api';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Button } from '../../../../../common/Button';
import { Column } from '../../../../../common/Column';
import { Grid } from '../../../../../common/Grid';
@@ -14,9 +14,9 @@ export const CatalogLayouGuildFrontpageView: FC<CatalogLayoutProps> = props =>
return (
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } />
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } />
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</Column>
<Column center overflow="hidden" size={ 5 }>
<LayoutImage imageUrl={ page.localization.getImage(1) } />
@@ -1,4 +1,5 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutInfoLoyaltyView: FC<CatalogLayoutProps> = props =>
@@ -8,7 +9,7 @@ export const CatalogLayoutInfoLoyaltyView: FC<CatalogLayoutProps> = props =>
return (
<div className="h-full nitro-catalog-layout-info-loyalty text-black flex flex-row">
<div className="overflow-auto h-full flex flex-col info-loyalty-content">
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div>
</div>
);
@@ -1,5 +1,6 @@
import { FC } from 'react';
import { FaPaw } from 'react-icons/fa';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
@@ -16,20 +17,20 @@ export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
<div>
<div className="flex items-center gap-1.5 mb-0.5">
<FaPaw className="text-primary text-xs" />
<span className="text-sm font-bold" dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
<span className="text-sm font-bold" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</div>
</div>
</div>
{ /* Content */ }
<div className="flex-1 overflow-auto bg-white rounded border-2 border-card-grid-item-border p-3">
<div className="text-[11px] leading-relaxed" dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } />
<div className="text-[11px] leading-relaxed" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } />
</div>
{ /* Footer */ }
{ !!page.localization.getText(3) &&
<div className="p-2 bg-card-grid-item rounded border border-card-grid-item-border">
<span className="text-[11px] font-bold" dangerouslySetInnerHTML={ { __html: page.localization.getText(3) } } />
<span className="text-[11px] font-bold" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(3)) } } />
</div> }
</div>
);
@@ -1,4 +1,5 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
@@ -17,7 +18,7 @@ export const CatalogLayoutRoomBundleView: FC<CatalogLayoutProps> = props =>
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } /> }
<Text dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
@@ -1,4 +1,5 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
@@ -17,7 +18,7 @@ export const CatalogLayoutSingleBundleView: FC<CatalogLayoutProps> = props =>
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } /> }
<Text dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
@@ -1,6 +1,6 @@
import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, OfficialSongIdMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SendMessageComposer } from '../../../../../api';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalog, useMessageEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
@@ -80,7 +80,7 @@ export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
<>
{ !!page.localization.getImage(1) &&
<LayoutImage imageUrl={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -1,4 +1,5 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
@@ -26,7 +27,7 @@ export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -1,6 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
import { LocalizeText, ProductTypeEnum } from '../../../../../api';
import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
@@ -99,7 +99,7 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
<FaTrophy className="text-warning text-[11px]" />
<span className="text-[12px] font-bold">{ LocalizeText('catalog.trophies.title') }</span>
</div>
<Text className="text-[10px]! text-muted leading-relaxed" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<Text className="text-[10px]! text-muted leading-relaxed" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div>
</div> }
@@ -1,6 +1,6 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SendMessageComposer } from '../../../../../api';
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
@@ -160,12 +160,12 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
);
}) }
</AutoGrid>
<Text center dangerouslySetInnerHTML={ { __html: LocalizeText('catalog.vip.buy.hccenter') } }></Text>
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(LocalizeText('catalog.vip.buy.hccenter')) } }></Text>
</Column>
<Column overflow="hidden" size={ 5 }>
<Column center fullHeight overflow="hidden">
{ currentPage.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: getSubscriptionDetails } } overflow="auto" />
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(getSubscriptionDetails) } } overflow="auto" />
</Column>
{ pendingOffer &&
<Column fullWidth grow justifyContent="end">
@@ -1,7 +1,7 @@
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCheck, FaEdit, FaFillDrip, FaPaw, FaPlus, FaTimes } from 'react-icons/fa';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SendMessageComposer } from '../../../../../../api';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../../api';
import { LayoutGridItem, LayoutPetImageView } from '../../../../../../common';
import { CatalogPurchaseFailureEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent } from '../../../../../../hooks';
@@ -249,7 +249,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
</div> }
{ !!page.localization.getText(0) &&
<p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> }
<p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
</div>
{ /* Name input */ }
@@ -87,6 +87,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
isPurchasingCatalogItem = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
setTimeout(() => { isPurchasingCatalogItem = false; }, 10000);
if(purchaseCallback)
{
purchaseCallback();
@@ -1,6 +1,6 @@
import { GetTargetedOfferComposer, PurchaseTargetedOfferComposer, TargetedOfferData } from '@nitrots/nitro-renderer';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { FriendlyTime, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../../api';
import { FriendlyTime, GetConfigurationValue, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { usePurse } from '../../../../hooks';
@@ -63,7 +63,7 @@ export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Disp
<h4>
{ LocalizeText(offer.title) }
</h4>
<div dangerouslySetInnerHTML={ { __html: offer.description } } />
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(offer.description) } } />
</Column>
<Flex alignItems="center" alignSelf="center" gap={ 2 } justifyContent="center">
{ offer.purchaseLimit > 1 &&