mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #251 from simoleo89/fix/sanitize-user-html
fix(security): client XSS & external-link hardening
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { RoomChatFormatter } from './RoomChatFormatter';
|
||||
|
||||
/**
|
||||
* Security + behaviour suite for the chat formatter.
|
||||
*
|
||||
* The formatter output is injected into the DOM via `dangerouslySetInnerHTML`
|
||||
* in ChatWidgetMessageView, so the security contract is: after the browser
|
||||
* parses the formatted string as HTML, NO attacker-controlled executable
|
||||
* markup may survive (no <script>/<img onerror>/<svg onload>/javascript: URL,
|
||||
* no event-handler attributes). We use jsdom's real HTML parser as the oracle
|
||||
* rather than guessing how entities decode.
|
||||
*/
|
||||
const parse = (input: string): HTMLDivElement =>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = RoomChatFormatter(input);
|
||||
return div;
|
||||
};
|
||||
|
||||
describe('RoomChatFormatter — XSS neutralisation', () =>
|
||||
{
|
||||
it('does not produce a <script> element from a raw script tag', () =>
|
||||
{
|
||||
const div = parse('<script>alert(1)</script>');
|
||||
expect(div.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not produce an <img> element with an onerror handler', () =>
|
||||
{
|
||||
const div = parse('<img src=x onerror=alert(1)>');
|
||||
const img = div.querySelector('img');
|
||||
expect(img).toBeNull();
|
||||
});
|
||||
|
||||
it('does not produce an <svg> element with an onload handler', () =>
|
||||
{
|
||||
const div = parse('<svg onload=alert(1)></svg>');
|
||||
expect(div.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not keep a javascript: href on an anchor', () =>
|
||||
{
|
||||
const div = parse('<a href="javascript:alert(1)">x</a>');
|
||||
expect(div.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips event-handler attributes injected via a font tag', () =>
|
||||
{
|
||||
const div = parse('<font color="red" onload="alert(1)">hi</font>');
|
||||
expect(div.querySelector('[onload]')).toBeNull();
|
||||
expect(div.innerHTML.toLowerCase()).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('neutralises numeric-entity-encoded image injection (<img …>)', () =>
|
||||
{
|
||||
const div = parse('<img src=x onerror=alert(1)>');
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('neutralises hex-entity-encoded image injection (<img …)', () =>
|
||||
{
|
||||
const div = parse('<img src=x onerror=alert(1)>');
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not allow an arbitrary style/background via font color', () =>
|
||||
{
|
||||
const div = parse('<font color="red;background:url(javascript:alert(1))">hi</font>');
|
||||
expect(div.innerHTML.toLowerCase()).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoomChatFormatter — legitimate markup is preserved', () =>
|
||||
{
|
||||
it('renders [b]…[/b] as <strong>', () =>
|
||||
{
|
||||
const div = parse('[b]hello[/b]');
|
||||
const strong = div.querySelector('strong');
|
||||
expect(strong).not.toBeNull();
|
||||
expect(strong?.textContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('renders a whitelisted font colour as a coloured span', () =>
|
||||
{
|
||||
const div = parse('<font color="red">hi</font>');
|
||||
const span = div.querySelector('span');
|
||||
expect(span).not.toBeNull();
|
||||
expect((span as HTMLElement).style.color).toBe('red');
|
||||
expect(span?.textContent).toBe('hi');
|
||||
});
|
||||
|
||||
it('drops a non-whitelisted font colour but keeps the inner text', () =>
|
||||
{
|
||||
const div = parse('<font color="notacolour">hi</font>');
|
||||
expect(div.textContent).toContain('hi');
|
||||
expect(div.innerHTML.toLowerCase()).not.toContain('notacolour');
|
||||
});
|
||||
|
||||
it('passes plain text through unchanged', () =>
|
||||
{
|
||||
const div = parse('just a normal message');
|
||||
expect(div.textContent).toBe('just a normal message');
|
||||
});
|
||||
|
||||
it('converts newlines to <br />', () =>
|
||||
{
|
||||
const div = parse('line1\nline2');
|
||||
expect(div.querySelectorAll('br').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SanitizeHtml } from './SanitizeHtml';
|
||||
|
||||
/**
|
||||
* SanitizeHtml is the project's shared HTML sanitiser (DOMPurify with a fixed
|
||||
* allow-list). It is the load-bearing defence wherever user/server-controlled
|
||||
* strings are rendered via `dangerouslySetInnerHTML`. These tests pin both the
|
||||
* security guarantee (no executable markup survives) and the formatting
|
||||
* guarantee (the limited markup the chat/profile UI relies on is preserved),
|
||||
* using jsdom's real parser as the oracle.
|
||||
*/
|
||||
const parse = (html: string): HTMLDivElement =>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = SanitizeHtml(html);
|
||||
return div;
|
||||
};
|
||||
|
||||
describe('SanitizeHtml — neutralises dangerous markup', () =>
|
||||
{
|
||||
it('removes <script> elements', () =>
|
||||
{
|
||||
expect(parse('<script>alert(1)</script>').querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips inline event handlers (onerror) from allowed tags', () =>
|
||||
{
|
||||
const div = parse('<img src=x onerror=alert(1)>');
|
||||
const img = div.querySelector('img');
|
||||
// img tag itself is allow-listed, but the handler must be gone
|
||||
expect(img?.getAttribute('onerror')).toBeNull();
|
||||
expect(div.innerHTML.toLowerCase()).not.toContain('onerror');
|
||||
});
|
||||
|
||||
it('drops javascript: URLs on anchors', () =>
|
||||
{
|
||||
const div = parse('<a href="javascript:alert(1)">x</a>');
|
||||
expect(div.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('removes <svg> and its onload handler', () =>
|
||||
{
|
||||
const div = parse('<svg onload=alert(1)></svg>');
|
||||
expect(div.innerHTML.toLowerCase()).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('removes <iframe> elements', () =>
|
||||
{
|
||||
expect(parse('<iframe src="https://evil.example"></iframe>').querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a plain username untouched', () =>
|
||||
{
|
||||
expect(SanitizeHtml('CoolUser_123')).toBe('CoolUser_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SanitizeHtml — preserves the markup the chat/profile UI relies on', () =>
|
||||
{
|
||||
it('keeps <b>/<i>/<u>', () =>
|
||||
{
|
||||
const div = parse('<b>a</b><i>b</i><u>c</u>');
|
||||
expect(div.querySelector('b')).not.toBeNull();
|
||||
expect(div.querySelector('i')).not.toBeNull();
|
||||
expect(div.querySelector('u')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps <strong>/<em> (RoomChatFormatter output)', () =>
|
||||
{
|
||||
const div = parse('<strong>x</strong><em>y</em>');
|
||||
expect(div.querySelector('strong')).not.toBeNull();
|
||||
expect(div.querySelector('em')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a coloured span (RoomChatFormatter output)', () =>
|
||||
{
|
||||
const div = parse('<span style="color:red">hi</span>');
|
||||
const span = div.querySelector('span');
|
||||
expect(span).not.toBeNull();
|
||||
expect((span as HTMLElement).style.color).toBe('red');
|
||||
});
|
||||
|
||||
it('keeps <br>', () =>
|
||||
{
|
||||
expect(parse('a<br />b').querySelectorAll('br').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SanitizeHtml — link safety', () =>
|
||||
{
|
||||
it('forces rel="noopener noreferrer" on a target=_blank anchor', () =>
|
||||
{
|
||||
const a = parse('<a href="https://example.com" target="_blank">x</a>').querySelector('a');
|
||||
expect(a).not.toBeNull();
|
||||
expect(a?.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('overrides an attacker-supplied rel on a target=_blank anchor', () =>
|
||||
{
|
||||
const a = parse('<a href="https://example.com" target="_blank" rel="opener">x</a>').querySelector('a');
|
||||
expect(a?.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,23 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Any link that opens a new browsing context gets a safe rel so it cannot
|
||||
// reverse-tabnab the opener. Registered once at module load; applies to every
|
||||
// SanitizeHtml() call (and overrides any attacker-supplied rel).
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node =>
|
||||
{
|
||||
const element = node as Element;
|
||||
|
||||
if((element.tagName === 'A') && element.getAttribute('target'))
|
||||
{
|
||||
element.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
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' ],
|
||||
ALLOWED_ATTR: [ 'href', 'target', 'class', 'style', 'src', 'alt', 'rel' ],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from './PrefixUtils';
|
||||
export * from './ProductImageUtility';
|
||||
export * from './Randomizer';
|
||||
export * from './RememberLogin';
|
||||
export * from './isSafeExternalUrl';
|
||||
export * from './RoomChatFormatter';
|
||||
export * from './SanitizeHtml';
|
||||
export * from './SetLocalStorage';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSafeExternalUrl } from './isSafeExternalUrl';
|
||||
|
||||
/**
|
||||
* Guard for URLs opened from user-controlled content (chat links, external
|
||||
* image/photo links). Only plain web URLs may be opened — never script- or
|
||||
* data-bearing schemes that run in the opener's origin.
|
||||
*/
|
||||
describe('isSafeExternalUrl', () =>
|
||||
{
|
||||
it('accepts http and https URLs', () =>
|
||||
{
|
||||
expect(isSafeExternalUrl('http://example.com/path')).toBe(true);
|
||||
expect(isSafeExternalUrl('https://example.com/path?q=1#x')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects javascript: URLs', () =>
|
||||
{
|
||||
expect(isSafeExternalUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeExternalUrl('JavaScript:alert(1)')).toBe(false);
|
||||
expect(isSafeExternalUrl(' javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects data: and vbscript: URLs', () =>
|
||||
{
|
||||
expect(isSafeExternalUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isSafeExternalUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects file: and other non-web schemes', () =>
|
||||
{
|
||||
expect(isSafeExternalUrl('file:///etc/passwd')).toBe(false);
|
||||
expect(isSafeExternalUrl('about:blank')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty / malformed input', () =>
|
||||
{
|
||||
expect(isSafeExternalUrl('')).toBe(false);
|
||||
expect(isSafeExternalUrl(null as unknown as string)).toBe(false);
|
||||
expect(isSafeExternalUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Returns true only for plain web URLs (http/https). Used to gate URLs that
|
||||
* originate from user-controlled content before they are opened — never let a
|
||||
* `javascript:`, `data:`, `vbscript:`, `file:` … scheme reach `window.open`,
|
||||
* which would run in the opener's origin.
|
||||
*/
|
||||
export const isSafeExternalUrl = (url: string): boolean =>
|
||||
{
|
||||
if(!url || (typeof url !== 'string')) return false;
|
||||
|
||||
try
|
||||
{
|
||||
const protocol = new URL(url.trim()).protocol;
|
||||
|
||||
return ((protocol === 'http:') || (protocol === 'https:'));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChatEntryType, LocalizeText } from '../../api';
|
||||
import { ChatEntryType, LocalizeText, SanitizeHtml } from '../../api';
|
||||
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
|
||||
import { useChatHistory, useMentionActions, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
||||
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||
@@ -137,8 +137,8 @@ export const ChatHistoryView: FC<{}> = props =>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-content">
|
||||
<b className="mr-1 username" dangerouslySetInnerHTML={{ __html: `${row.name}: ` }} />
|
||||
<span className="message" dangerouslySetInnerHTML={{ __html: `${row.message}` }} onClick={ onClickChat } />
|
||||
<b className="mr-1 username" dangerouslySetInnerHTML={{ __html: SanitizeHtml(`${row.name}: `) }} />
|
||||
<span className="message" dangerouslySetInnerHTML={{ __html: SanitizeHtml(`${row.message}`) }} onClick={ onClickChat } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -321,7 +321,7 @@ export const GuideToolView: FC<{}> = props =>
|
||||
return;
|
||||
case 'forum_link':
|
||||
const url: string = GetConfigurationValue<string>('group.homepage.url', '').replace('%groupid%', GetConfigurationValue<string>('guide.help.alpha.groupid', '0'));
|
||||
window.open(url);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
}, [ isHandlingBullyReports, isHandlingGuideRequests, isHandlingHelpRequests, simpleAlert ]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { ChatEntryType, IReportedUser, LocalizeText, ReportState } from '../../../api';
|
||||
import { ChatEntryType, IReportedUser, LocalizeText, ReportState, SanitizeHtml } from '../../../api';
|
||||
import { AutoGrid, Button, Column, Flex, LayoutGridItem, Text } from '../../../common';
|
||||
import { useChatHistory, useHelp } from '../../../hooks';
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SelectReportedUserView: FC<{}> = props =>
|
||||
{
|
||||
return (
|
||||
<LayoutGridItem key={ user.id } itemActive={ (selectedUserId === user.id) } onClick={ event => selectUser(user.id) }>
|
||||
<span dangerouslySetInnerHTML={ { __html: (user.username) } } />
|
||||
<span dangerouslySetInnerHTML={ { __html: SanitizeHtml(user.username) } } />
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||
import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent, SanitizeHtml } from '../../../../api';
|
||||
import { Button, Column, Flex, LayoutNotificationAlertView, LayoutNotificationAlertViewProps } from '../../../../common';
|
||||
|
||||
interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps
|
||||
@@ -97,7 +97,7 @@ export const NotificationDefaultAlertView: FC<NotificationDefaultAlertViewProps>
|
||||
{
|
||||
const htmlText = message.replace(/\r\n|\r|\n/g, '<br />');
|
||||
|
||||
return <div key={ index } dangerouslySetInnerHTML={ { __html: htmlText } } />;
|
||||
return <div key={ index } dangerouslySetInnerHTML={ { __html: SanitizeHtml(htmlText) } } />;
|
||||
}) }
|
||||
{ item.clickUrl && (item.clickUrl.length > 0) && (item.imageUrl && !imageFailed) && <>
|
||||
<hr className="my-2 w-full" />
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { FC } from 'react';
|
||||
import { NotificationBubbleItem, OpenUrl } from '../../../../api';
|
||||
import { NotificationBubbleItem, OpenUrl, SanitizeHtml } from '../../../../api';
|
||||
import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
|
||||
|
||||
export interface NotificationDefaultBubbleViewProps extends LayoutNotificationBubbleViewProps
|
||||
@@ -19,7 +19,7 @@ export const NotificationDefaultBubbleView: FC<NotificationDefaultBubbleViewProp
|
||||
{ (item.iconUrl && item.iconUrl.length) &&
|
||||
<img alt="" className="no-select" src={ item.iconUrl } /> }
|
||||
</Flex>
|
||||
<Text wrap dangerouslySetInnerHTML={ { __html: htmlText } } variant="white" />
|
||||
<Text wrap dangerouslySetInnerHTML={ { __html: SanitizeHtml(htmlText) } } variant="white" />
|
||||
</LayoutNotificationBubbleView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateLinkEvent, FlatControllerAddedEvent, FlatControllerRemovedEvent, GetSessionDataManager, RoomControllerLevel, RoomObjectCategory, RoomObjectVariable, RoomUnitGiveHandItemComposer, SetRelationshipStatusComposer, TradingOpenComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { AvatarInfoUser, DispatchUiEvent, GetOwnRoomObject, GetUserProfile, LocalizeText, MessengerFriend, ReportType, RoomWidgetUpdateChatInputContentEvent, SendMessageComposer } from '../../../../../api';
|
||||
import { AvatarInfoUser, DispatchUiEvent, GetOwnRoomObject, GetUserProfile, LocalizeText, MessengerFriend, ReportType, RoomWidgetUpdateChatInputContentEvent, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { Flex } from '../../../../../common';
|
||||
import { useFriends, useHelp, useIsUserIgnored, useMessageEvent, useRoom, useSessionInfo, useWiredTools } from '../../../../../hooks';
|
||||
import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView';
|
||||
@@ -244,7 +244,7 @@ export const AvatarInfoWidgetAvatarView: FC<AvatarInfoWidgetAvatarViewProps> = p
|
||||
|
||||
return (
|
||||
<ContextMenuView category={ RoomObjectCategory.UNIT } classNames={ [ 'nitro-avatar-action-menu' ] } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
|
||||
<ContextMenuHeaderView className="cursor-pointer" onClick={ event => GetUserProfile(avatarInfo.webID) } dangerouslySetInnerHTML={ { __html: `${ avatarInfo.name }` } }></ContextMenuHeaderView>
|
||||
<ContextMenuHeaderView className="cursor-pointer" onClick={ event => GetUserProfile(avatarInfo.webID) } dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ avatarInfo.name }`) } }></ContextMenuHeaderView>
|
||||
{ (mode === MODE_NORMAL) &&
|
||||
<>
|
||||
{ canRequestFriend(avatarInfo.webID) &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { AvatarInfoName } from '../../../../../api';
|
||||
import { AvatarInfoName, SanitizeHtml } from '../../../../../api';
|
||||
import { ContextMenuView } from '../../context-menu/ContextMenuView';
|
||||
|
||||
interface AvatarInfoWidgetNameViewProps
|
||||
@@ -24,7 +24,7 @@ export const AvatarInfoWidgetNameView: FC<AvatarInfoWidgetNameViewProps> = props
|
||||
|
||||
return (
|
||||
<ContextMenuView category={ nameInfo.category } classNames={ getClassNames } fades={ (nameInfo.id !== GetSessionDataManager().userId) } objectId={ nameInfo.roomIndex } userType={ nameInfo.userType } onClose={ onClose }>
|
||||
<div className="text-shadow" dangerouslySetInnerHTML={ { __html: `${ nameInfo.name }` } }></div>
|
||||
<div className="text-shadow" dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ nameInfo.name }`) } }></div>
|
||||
</ContextMenuView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer';
|
||||
import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChatEntryType, LocalizeText } from '../../../../api';
|
||||
import { ChatEntryType, LocalizeText, SanitizeHtml } from '../../../../api';
|
||||
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks';
|
||||
import { useRoom } from '../../../../hooks/rooms';
|
||||
@@ -133,18 +133,18 @@ export const ChatWidgetWindowView: FC<{}> = () =>
|
||||
{ hideBalloons && !hideAvatars && <div className={ `w-[65px] h-[55px] shrink-0 mt-[-18px] rounded-sm bg-no-repeat bg-center scale-70 ${ isOwnMessage ? 'order-2' : '' }` } style={ chat.imageUrl ? { backgroundImage: `url(${ chat.imageUrl })` } : undefined } /> }
|
||||
{ hideBalloons && (
|
||||
<div onClick={ onClickChat }>
|
||||
<b dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
|
||||
<b dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ chat.name }: `) } } />
|
||||
{ !chat.showTranslation &&
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.message } } /> }
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(chat.message) } } /> }
|
||||
{ chat.showTranslation &&
|
||||
<div className="mt-[2px] flex flex-col gap-[2px]">
|
||||
<div className="flex items-start gap-1 leading-[1.15]">
|
||||
<span className="inline-block min-w-[52px] font-bold opacity-75">original:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.originalMessage || chat.message || '' } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(chat.originalMessage || chat.message || '') } } />
|
||||
</div>
|
||||
<div className="flex items-start gap-1 leading-[1.15]">
|
||||
<span className="inline-block min-w-[52px] font-bold opacity-75">translate:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.translatedMessage || chat.message || '' } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(chat.translatedMessage || chat.message || '') } } />
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
@@ -161,18 +161,18 @@ export const ChatWidgetWindowView: FC<{}> = () =>
|
||||
) }
|
||||
</div>
|
||||
<div className={ `chat-content py-[5px] px-[6px] leading-none min-h-[25px] ${ !hideAvatars ? (isOwnMessage ? 'mr-[27px]' : 'ml-[27px]') : '' }` }>
|
||||
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
|
||||
<b className="username" dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ chat.name }: `) } } />
|
||||
{ !chat.showTranslation &&
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } onClick={ onClickChat } /> }
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ chat.message }`) } } onClick={ onClickChat } /> }
|
||||
{ chat.showTranslation &&
|
||||
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
|
||||
<div className="flex items-start gap-1 leading-[1.1]">
|
||||
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalMessage || chat.message || '' }` } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ chat.originalMessage || chat.message || '' }`) } } />
|
||||
</div>
|
||||
<div className="flex items-start gap-1 leading-[1.1]">
|
||||
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedMessage || chat.message || '' }` } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: SanitizeHtml(`${ chat.translatedMessage || chat.message || '' }`) } } />
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { GetConfigurationValue, LocalizeText, ReportType } from '../../../../api';
|
||||
import { GetConfigurationValue, isSafeExternalUrl, LocalizeText, ReportType } from '../../../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useFurnitureExternalImageWidget, useHelp } from '../../../../hooks';
|
||||
import { CameraWidgetShowPhotoView } from '../../../camera/views/CameraWidgetShowPhotoView';
|
||||
@@ -15,10 +15,9 @@ export const FurnitureExternalImageView: FC<{}> = props =>
|
||||
const handleOpenFullPhoto = () =>
|
||||
{
|
||||
const photoUrl = currentPhotos[currentPhotoIndex].w.replace('_small.png', '.png');
|
||||
if (photoUrl)
|
||||
if (photoUrl && isSafeExternalUrl(photoUrl))
|
||||
{
|
||||
console.log('Opened photo URL:', photoUrl);
|
||||
window.open(photoUrl, '_blank');
|
||||
window.open(photoUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -639,7 +639,7 @@ export const YouTubePlayerView: FC<{}> = () =>
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
||||
'Now watching: https://youtube.com/watch?v=${videoId}',
|
||||
)}`;
|
||||
window.open(url, '_blank');
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
disabled={!videoId}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { LocalizeText } from '../api';
|
||||
import { isSafeExternalUrl, LocalizeText } from '../api';
|
||||
import { useNotification } from './notification';
|
||||
|
||||
const useOnClickChatState = () =>
|
||||
@@ -15,9 +15,13 @@ const useOnClickChatState = () =>
|
||||
|
||||
const url = event.target.href;
|
||||
|
||||
// Never open a URL that came from chat unless it is a plain web link —
|
||||
// a javascript:/data: href would otherwise run in our origin.
|
||||
if(!isSafeExternalUrl(url)) return;
|
||||
|
||||
showConfirm(LocalizeText('chat.confirm.openurl', [ 'url' ], [ url ]), () =>
|
||||
{
|
||||
window.open(url, '_blank');
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, null, null, null, LocalizeText('generic.alert.title'), null);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user