mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
144 lines
4.1 KiB
TypeScript
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));
|
|
}
|