Sanitize dApp origin to prevent spoofing (#1554)
* feat: add origin sanitization and validation in AccountSelection component * fix: added translation
This commit is contained in:
@@ -8,6 +8,7 @@ import { AccountContext, Checkbox, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import AccountsTree from '../Popup/Accounts/AccountsTree.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { sanitizeOrigin, validateOrigin } from '../util/sanitizeOrigin.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -20,7 +21,10 @@ interface Props {
|
||||
function AccountSelection ({ className, origin, showHidden = false, url, withWarning = true }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts, hierarchy, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
|
||||
const [sanitizedOrigin, setSanitizedOrigin] = useState('');
|
||||
const [isSuspiciousOrigin, setIsSuspiciousOrigin] = useState(false);
|
||||
const [isIndeterminate, setIsIndeterminate] = useState(false);
|
||||
|
||||
const allVisibleAccounts = useMemo(() => accounts.filter(({ isHidden }) => !isHidden), [accounts]);
|
||||
const noAccountSelected = useMemo(() => selectedAccounts.length === 0, [selectedAccounts.length]);
|
||||
const allDisplayedAddresses = useMemo(
|
||||
@@ -34,6 +38,14 @@ function AccountSelection ({ className, origin, showHidden = false, url, withWar
|
||||
, [allDisplayedAddresses.length, selectedAccounts.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cleaned = sanitizeOrigin(origin);
|
||||
const isValid = validateOrigin(origin);
|
||||
|
||||
setSanitizedOrigin(cleaned);
|
||||
setIsSuspiciousOrigin(!isValid || cleaned !== origin);
|
||||
}, [origin]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextIndeterminateState = !noAccountSelected && !areAllAccountsSelected;
|
||||
|
||||
@@ -55,7 +67,7 @@ function AccountSelection ({ className, origin, showHidden = false, url, withWar
|
||||
<div className={className}>
|
||||
{withWarning && (
|
||||
<Warning className='warningMargin'>
|
||||
<Trans key='accessRequest'>An application, self-identifying as <span className='tab-name'>{origin}</span> is requesting access from{' '}
|
||||
<Trans key='accessRequest'>An application, self-identifying as <span className='tab-name'>{sanitizedOrigin}</span> is requesting access from{' '}
|
||||
<a
|
||||
href={url}
|
||||
rel='noopener noreferrer'
|
||||
@@ -66,6 +78,11 @@ function AccountSelection ({ className, origin, showHidden = false, url, withWar
|
||||
</Trans>
|
||||
</Warning>
|
||||
)}
|
||||
{isSuspiciousOrigin && (
|
||||
<Warning className='warningError'>
|
||||
{t('It looks like this request is coming from an suspicious origin. Please verify the source carefully.')}
|
||||
</Warning>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={areAllAccountsSelected}
|
||||
className='accountTree-checkbox'
|
||||
@@ -123,4 +140,15 @@ export default styled(AccountSelection)<Props>`
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
|
||||
.warningError {
|
||||
padding-block: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--errorBorderColor);
|
||||
background-color: #AF111170;
|
||||
|
||||
[data-theme="light"] & {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2025 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
const sanitizeHtml = (input: string, options?: {
|
||||
allowLineBreaks?: boolean;
|
||||
maxLength?: number;
|
||||
}): string => {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = input;
|
||||
|
||||
// Remove all HTML tags
|
||||
sanitized = sanitized.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Handle line breaks if specified
|
||||
if (options?.allowLineBreaks) {
|
||||
// Convert <br> to actual line breaks before sanitization
|
||||
sanitized = input
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, ''); // Remove other HTML tags
|
||||
}
|
||||
|
||||
// Normalize whitespace
|
||||
sanitized = sanitized
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
// Apply length limit if specified
|
||||
if (options?.maxLength && sanitized.length > options.maxLength) {
|
||||
sanitized = sanitized.substring(0, options.maxLength) + '...';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const sanitizeOrigin = (origin: string): string => {
|
||||
return sanitizeHtml(origin, {
|
||||
allowLineBreaks: false,
|
||||
maxLength: 50
|
||||
});
|
||||
};
|
||||
|
||||
// Validate that the origin looks legitimate
|
||||
export const validateOrigin = (origin: string): boolean => {
|
||||
const sanitized = sanitizeOrigin(origin);
|
||||
|
||||
// Check for suspicious patterns
|
||||
const suspiciousPatterns = [
|
||||
/https?:\/\//i, // URLs in origin name
|
||||
/[<>]/, // Remaining HTML characters
|
||||
/^\s*$/, // Empty or whitespace only
|
||||
/.{50,}/ // Extremely long names
|
||||
];
|
||||
|
||||
return !suspiciousPatterns.some((pattern) => pattern.test(sanitized));
|
||||
};
|
||||
@@ -181,5 +181,6 @@
|
||||
"This network is not available, please report an issue to update the known chains": "",
|
||||
"Ledger App": "",
|
||||
"Don't ask again": "",
|
||||
"Previous": ""
|
||||
"Previous": "",
|
||||
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user