n8n/packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts
2026-08-13 13:11:18 +00:00

144 lines
4.1 KiB
TypeScript

import { toValue, type MaybeRef } from 'vue';
import xss from 'xss';
import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from './constants/sanitization';
/*
Constants and utility functions that help in HTML, CSS and DOM manipulation
*/
// `xss` is CJS with no `exports` map, so Node's lexer cannot see these as named
// exports and `import { ... }` throws at link time under native ESM.
const { escapeAttrValue, escapeHtml } = xss as unknown as {
escapeAttrValue: (str: string) => string;
escapeHtml: (str: string) => string;
};
/**
* Escapes HTML entities in a string to prevent HTML injection.
* This should be used for user input that will be displayed in HTML contexts.
* Unlike sanitizeHtml, this completely escapes all HTML entities.
*/
export { escapeHtml };
export function sanitizeHtml(dirtyHtml: string) {
const sanitizedHtml = xss(dirtyHtml, {
onTagAttr: (tag, name, value) => {
if (tag === 'img' && name === 'src') {
// Only allow http requests to supported image files from the `static` directory
const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
const isStaticImageFile = isImageFile && value.startsWith('/static/');
if (!value.startsWith('https://') && !isStaticImageFile) {
return '';
}
}
if (ALLOWED_HTML_ATTRIBUTES.includes(name) || name.startsWith('data-')) {
// href is allowed but we allow only https and relative URLs
if (name === 'href' && !value.match(/^https?:\/\//) && !value.startsWith('/')) {
return '';
}
return `${name}="${escapeAttrValue(value)}"`;
}
return;
// Return nothing, means keep the default handling measure
},
onTag: (tag) => {
if (!ALLOWED_HTML_TAGS.includes(tag)) return '';
return;
},
});
return sanitizedHtml;
}
const SAFE_OPEN_PROTOCOLS = ['http:', 'https:'];
/**
* Opens a URL in a new tab, but only when it resolves to an http(s) protocol.
* Persisted, untrusted values (e.g. resource locator URLs) can carry schemes
* like `javascript:` or `data:`, so anything else is dropped. Always opens with
* `noopener,noreferrer` so the new tab cannot reach back to the opener window.
*/
export function openSafeUrl(url: string): void {
let protocol: string;
try {
protocol = new URL(url, window.location.origin).protocol;
} catch {
return;
}
if (SAFE_OPEN_PROTOCOLS.includes(protocol)) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
/**
* Checks if the input is a string and sanitizes it by removing or escaping harmful characters,
* returning the original input if it's not a string.
*/
export const sanitizeIfString = <T>(message: T): string | T => {
if (typeof message === 'string') {
return sanitizeHtml(message);
}
return message;
};
export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
export const getBannerRowHeight = async (): Promise<number> => {
return await new Promise((resolve) => {
setTimeout(() => {
resolve(document.getElementById('banners')?.clientHeight ?? 0);
}, 0);
});
};
export function isOutsideSelected(el: HTMLElement | null) {
const selection = document.getSelection();
if (!selection?.anchorNode || !selection.focusNode || !el) {
return false;
}
return (
!el.contains(selection.anchorNode) &&
!el.contains(selection.focusNode) &&
(selection.anchorNode !== selection.focusNode ||
selection.anchorOffset !== selection.focusOffset)
);
}
let scrollbarWidth: number | undefined;
export function getScrollbarWidth() {
if (scrollbarWidth !== undefined) {
return scrollbarWidth;
}
const outer = document.createElement('div');
const inner = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
document.body.appendChild(outer);
outer.appendChild(inner);
scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentElement?.removeChild(outer);
return scrollbarWidth;
}
export function isEventTargetContainedBy(
eventTarget: EventTarget | null | undefined,
maybeContainer: MaybeRef<HTMLElement | null | undefined>,
): boolean {
return !!(eventTarget instanceof Node && toValue(maybeContainer)?.contains(eventTarget));
}