Files
pezkuwi-extension/packages/extension-ui/src/util/sanitizeOrigin.ts
T
SatoshiQaziMuhammed d4c8c5f26e fix(ui): leave no angle bracket in a sanitized origin; judge markup on the raw one (#16)
* fix(ui): leave no angle bracket in a sanitized origin; judge markup on the raw one

sanitizeOrigin removed tags in a single pass, so `<scr<script>ipt>`
came out as `<script` (CodeQL js/incomplete-multi-character-sanitization
#6, #7). The page renders the result as React text, so this was not
exploitable here, but the helper's name promised more than it did. It
now also drops every remaining `<` and `>`.

validateOrigin looked for markup in the sanitized string, after the tags
had been removed, so `<b>x</b>` passed as a valid origin. It now checks
the raw origin first.

pr-any.yml gets permissions: contents: read and lock.yml the
issues/pull-requests/discussions write it needs (CodeQL
actions/missing-workflow-permissions #3, #23).

* test(ui): typed cases and the matchers the test shim implements

The first version used it.each (untyped rows, @typescript-eslint/no-unsafe-argument) and expect().not.toMatch, which @pezkuwi/dev-test does not implement. Plain loops over string[] and a boolean assertion instead. With the repo runner: 5/5 pass on the fix, 3 of 5 fail on main's sanitizeOrigin.ts.
2026-10-20 23:00:57 +04:00

74 lines
2.2 KiB
TypeScript

// Copyright 2019-2026 @pezkuwi/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
}
// Tag removal is a single pass, so nested input such as `<scr<script>ipt>`
// leaves a new tag behind. Drop every remaining angle bracket; an origin never
// contains one, and validateOrigin already treats them as suspicious.
sanitized = sanitized.replace(/[<>]/g, '');
// 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 => {
// Markup is judged on what the site sent: the sanitized form no longer has any.
if (/[<>]/.test(origin)) {
return false;
}
const sanitized = sanitizeOrigin(origin);
// Special case: always allow localhost URLs
if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(?::\d+)?(?:\/|$)/.test(sanitized)) {
return true;
}
// Check for suspicious patterns
const suspiciousPatterns = [
/^\s*$/, // Empty or whitespace only
/.{50,}/, // Extremely long names
/^(?!https?:\/\/).*:\/\//, // Any protocol other than http/https
/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?(?:\/|$)/ // Raw IP addresses (except localhost)
];
return !suspiciousPatterns.some((pattern) => pattern.test(sanitized));
};