feat: enhance sanitization

This commit is contained in:
alam00000
2026-04-17 23:40:39 +05:30
parent d92ee1a003
commit b4779bb49b
35 changed files with 2703 additions and 1240 deletions
+9 -23
View File
@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
import { state, resetState } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist';
import DOMPurify from 'dompurify';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
const STANDARD_SIZES = {
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
export function sanitizeEmailHtml(html: string): string {
if (!html) return html;
let sanitized = html;
let sanitized = DOMPurify.sanitize(html, {
FORBID_TAGS: ['style', 'link', 'script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['style'],
ALLOW_DATA_ATTR: false,
});
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
(match, encodedUrl) => {
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
}
}
);
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/href=["']([^"']{500,})["']/gi,
(match, url) => {
(_match, url: string) => {
const baseUrl = url.split('?')[0];
if (baseUrl && baseUrl.length < 200) {
return `href="${baseUrl}"`;
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
return `href="${url.substring(0, 200)}"`;
}
);
sanitized = sanitized.replace(
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi,
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
@@ -373,10 +363,6 @@ export function sanitizeEmailHtml(html: string): string {
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
sanitized = sanitized.replace(
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
'$1'
);
const MAX_HTML_SIZE = 100000;
if (sanitized.length > MAX_HTML_SIZE) {
+11 -4
View File
@@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
@@ -297,7 +298,7 @@ export class MarkdownEditor {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
securityLevel: 'strict',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
@@ -691,7 +692,9 @@ export class MarkdownEditor {
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = html;
this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.renderMermaidDiagrams();
}
@@ -714,7 +717,9 @@ export class MarkdownEditor {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg;
wrapper.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
pre.replaceWith(wrapper);
} catch (error) {
@@ -740,7 +745,9 @@ export class MarkdownEditor {
}
public getHtml(): string {
return this.md.render(this.getContent());
return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
}
private exportPdf(): void {
+75 -5
View File
@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
};
function envOrDefault(envVar: string | undefined, fallback: string): string {
@@ -30,20 +30,77 @@ const ENV_DEFAULTS: Record<WasmPackage, string> = {
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
};
function hostnameOf(url: string): string | null {
try {
return new URL(url).hostname;
} catch {
return null;
}
}
function collectBuiltinTrustedHosts(): Set<string> {
const hosts = new Set<string>();
if (typeof location !== 'undefined' && location.hostname) {
hosts.add(location.hostname);
}
for (const url of Object.values(CDN_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
for (const url of Object.values(ENV_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
return hosts;
}
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
class WasmProviderManager {
private config: WasmProviderConfig;
private validationCache: Map<WasmPackage, boolean> = new Map();
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
constructor() {
this.config = this.loadConfig();
}
private isTrustedUrl(url: string): boolean {
const host = hostnameOf(url);
return !!host && this.trustedHosts.has(host);
}
private loadConfig(): WasmProviderConfig {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
if (!stored) return {};
const parsed = JSON.parse(stored) as WasmProviderConfig;
const safe: WasmProviderConfig = {};
let dropped = false;
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
const url = parsed[key];
if (typeof url !== 'string') continue;
if (this.isTrustedUrl(url)) {
safe[key] = url;
} else {
dropped = true;
console.warn(
`[WasmProvider] Ignoring untrusted stored URL for ${key}: ${url}. ` +
'Reconfigure via Advanced Settings to re-enable.'
);
}
}
if (dropped) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
} catch (e) {
console.error(
'[WasmProvider] Failed to scrub untrusted config from localStorage:',
e
);
}
}
return safe;
} catch (e) {
console.warn(
'[WasmProvider] Failed to load config from localStorage:',
@@ -66,11 +123,23 @@ class WasmProviderManager {
}
getUrl(packageName: WasmPackage): string | undefined {
return this.config[packageName] || this.getEnvDefault(packageName);
const stored = this.config[packageName];
if (stored) {
if (this.isTrustedUrl(stored)) return stored;
console.warn(
`[WasmProvider] Refusing to use untrusted URL for ${packageName}; falling back to env default.`
);
}
return this.getEnvDefault(packageName);
}
setUrl(packageName: WasmPackage, url: string): void {
const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
}
this.trustedHosts.add(host);
this.config[packageName] = normalizedUrl;
this.validationCache.delete(packageName);
this.saveConfig();
@@ -219,6 +288,7 @@ class WasmProviderManager {
clearAll(): void {
this.config = {};
this.validationCache.clear();
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {