Sanitize dApp origin to prevent spoofing (#1554)

* feat: add origin sanitization and validation in AccountSelection component

* fix: added translation
This commit is contained in:
Arjun Porwal
2025-06-10 19:01:56 +05:30
committed by GitHub
parent 9f29135a0a
commit 17ec864a2e
3 changed files with 89 additions and 2 deletions
@@ -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.": ""
}