fixing rolodex and search
This commit is contained in:
58
static/js/__tests__/alerts.test.js
Normal file
58
static/js/__tests__/alerts.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
// Load sanitizer utility first so alerts can delegate to it
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
// Load the alerts module (IIFE attaches itself to window)
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
|
||||
describe('alerts._sanitize', () => {
|
||||
const sanitize = window.alerts && window.alerts._sanitize;
|
||||
|
||||
it('should be a function', () => {
|
||||
expect(typeof sanitize).toBe('function');
|
||||
});
|
||||
|
||||
it('removes <script> tags and event-handler attributes', () => {
|
||||
const dirty = '<img src="x" onerror="alert(1)"><script>alert("x")</script><p>Hello</p>';
|
||||
const clean = sanitize(dirty);
|
||||
expect(clean).toContain('<img src="x">');
|
||||
expect(clean).toContain('<p>Hello</p>');
|
||||
expect(clean).not.toMatch(/<script/i);
|
||||
expect(clean).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('uses DOMPurify after it is lazily loaded', async () => {
|
||||
// Ensure DOMPurify is not present initially
|
||||
delete window.DOMPurify;
|
||||
|
||||
const mockPurify = {
|
||||
sanitize: jest.fn((html) => `CLEAN:${html}`)
|
||||
};
|
||||
|
||||
// Spy on the shared sanitizer loader and inject DOMPurify once called
|
||||
const loaderSpy = jest
|
||||
.spyOn(window.htmlSanitizer, 'ensureDOMPurifyLoaded')
|
||||
.mockImplementation(() => {
|
||||
window.DOMPurify = mockPurify;
|
||||
return Promise.resolve(mockPurify);
|
||||
});
|
||||
|
||||
const dirty = '<span onclick="evil()">Hi</span>';
|
||||
|
||||
// First call: fallback sanitizer, DOMPurify not used yet
|
||||
const first = sanitize(dirty);
|
||||
expect(mockPurify.sanitize).not.toHaveBeenCalled();
|
||||
expect(loaderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for loader promise to resolve
|
||||
await loaderSpy.mock.results[0].value;
|
||||
|
||||
// Second call: should use DOMPurify
|
||||
const second = sanitize(dirty);
|
||||
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(`CLEAN:${dirty}`);
|
||||
|
||||
loaderSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
56
static/js/__tests__/sanitizer.test.js
Normal file
56
static/js/__tests__/sanitizer.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
require('../sanitizer.js');
|
||||
|
||||
describe('htmlSanitizer', () => {
|
||||
it('escape() encodes special HTML chars', () => {
|
||||
const { escape } = window.htmlSanitizer;
|
||||
expect(escape('<div>')).toBe('<div>');
|
||||
expect(escape('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(escape('"quotes" and \'apostrophes\'')).toContain('"');
|
||||
});
|
||||
|
||||
it('sanitize() returns safe HTML and does not double-escape plain text', () => {
|
||||
const { sanitize, escape } = window.htmlSanitizer;
|
||||
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
|
||||
const clean = sanitize(dirty);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('<p>Hello</p>');
|
||||
expect(clean).not.toMatch(/onerror/i);
|
||||
|
||||
const text = '<b>bold</b>';
|
||||
const escaped = escape(text);
|
||||
const sanitizedEscaped = sanitize(escaped);
|
||||
expect(sanitizedEscaped).toBe(escaped);
|
||||
});
|
||||
|
||||
it('setSafeHTML sets sanitized HTML on the element', () => {
|
||||
const el = document.createElement('div');
|
||||
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
|
||||
window.setSafeHTML(el, dirty);
|
||||
expect(el.innerHTML).toContain('<img');
|
||||
expect(el.innerHTML).toContain('<p>Hello</p>');
|
||||
expect(el.innerHTML).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('setSafeHTML uses DOMPurify when it becomes available after first call', () => {
|
||||
// Ensure not present initially
|
||||
delete window.DOMPurify;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const html = '<em>hello</em>';
|
||||
|
||||
// First call: fallback sanitizer (no DOMPurify)
|
||||
window.setSafeHTML(el, html);
|
||||
|
||||
// Now make DOMPurify available
|
||||
const mockPurify = { sanitize: jest.fn((h) => `CLEAN:${h}`) };
|
||||
window.DOMPurify = mockPurify;
|
||||
|
||||
// Second call should use DOMPurify
|
||||
window.setSafeHTML(el, html);
|
||||
|
||||
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
|
||||
expect(el.innerHTML).toBe(`CLEAN:${html}`);
|
||||
});
|
||||
});
|
||||
@@ -12,20 +12,20 @@
|
||||
|
||||
const TYPE_CLASSES = {
|
||||
success: {
|
||||
container: 'border-success-200 dark:border-success-800',
|
||||
icon: 'fa-solid fa-circle-check text-success-600 dark:text-success-400'
|
||||
container: 'border-green-300 dark:border-green-500 bg-green-50 dark:bg-green-800',
|
||||
icon: 'fa-solid fa-circle-check text-green-600 dark:text-green-300'
|
||||
},
|
||||
danger: {
|
||||
container: 'border-danger-200 dark:border-danger-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-danger-600 dark:text-danger-400'
|
||||
container: 'border-red-300 dark:border-red-500 bg-red-50 dark:bg-red-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-red-600 dark:text-red-300'
|
||||
},
|
||||
warning: {
|
||||
container: 'border-warning-200 dark:border-warning-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-warning-600 dark:text-warning-400'
|
||||
container: 'border-yellow-300 dark:border-yellow-500 bg-yellow-50 dark:bg-yellow-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-yellow-600 dark:text-yellow-300'
|
||||
},
|
||||
info: {
|
||||
container: 'border-info-200 dark:border-info-800',
|
||||
icon: 'fa-solid fa-circle-info text-info-600 dark:text-info-400'
|
||||
container: 'border-blue-300 dark:border-blue-500 bg-blue-50 dark:bg-blue-800',
|
||||
icon: 'fa-solid fa-circle-info text-blue-600 dark:text-blue-300'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,30 @@
|
||||
return TYPE_ALIASES[key] || 'info';
|
||||
}
|
||||
|
||||
// ---- DOMPurify Lazy Loader ------------------------------------------------
|
||||
// Delegated sanitizer: uses shared htmlSanitizer if available, else performs a minimal fallback
|
||||
function sanitizeHTML(dirty) {
|
||||
if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') {
|
||||
return window.htmlSanitizer.sanitize(dirty);
|
||||
}
|
||||
|
||||
// Minimal inline fallback to guarantee some protection until sanitizer.js loads
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = dirty;
|
||||
temp.querySelectorAll('script, style').forEach((el) => el.remove());
|
||||
temp.querySelectorAll('*').forEach((el) => {
|
||||
Array.from(el.attributes).forEach((attr) => {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (/^on/i.test(name)) el.removeAttribute(name);
|
||||
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
|
||||
el.removeAttribute(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
function getOrCreateContainer(containerId = 'notification-container') {
|
||||
let container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
@@ -63,7 +87,7 @@
|
||||
const container = getOrCreateContainer(containerId);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `alert-notification max-w-sm w-[22rem] bg-white dark:bg-neutral-800 border rounded-lg shadow-lg p-4 transition-all duration-300 translate-x-4 opacity-0 ${
|
||||
wrapper.className = `alert-notification max-w-sm w-[22rem] border-2 rounded-lg shadow-xl p-4 transition-all duration-300 translate-x-4 opacity-0 ${
|
||||
(TYPE_CLASSES[tone] || TYPE_CLASSES.info).container
|
||||
}`;
|
||||
wrapper.setAttribute('role', role);
|
||||
@@ -84,17 +108,17 @@
|
||||
|
||||
if (title) {
|
||||
const titleEl = document.createElement('p');
|
||||
titleEl.className = 'text-sm font-semibold text-neutral-900 dark:text-neutral-100';
|
||||
titleEl.className = 'text-sm font-bold text-neutral-900 dark:text-white';
|
||||
titleEl.textContent = String(title);
|
||||
content.appendChild(titleEl);
|
||||
}
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'text-xs mt-1 text-neutral-800 dark:text-neutral-200';
|
||||
text.className = 'text-sm mt-1 font-semibold text-neutral-900 dark:text-white';
|
||||
if (message instanceof Node) {
|
||||
text.appendChild(message);
|
||||
} else if (html) {
|
||||
text.innerHTML = String(message || '');
|
||||
text.innerHTML = sanitizeHTML(String(message || ''));
|
||||
} else {
|
||||
text.textContent = String(message || '');
|
||||
}
|
||||
@@ -177,7 +201,10 @@
|
||||
error: (message, options = {}) => show(message, 'danger', options),
|
||||
warning: (message, options = {}) => show(message, 'warning', options),
|
||||
info: (message, options = {}) => show(message, 'info', options),
|
||||
getOrCreateContainer
|
||||
getOrCreateContainer,
|
||||
// Internal: exposed for unit testing only (non-enumerable by default prototype iteration)
|
||||
_sanitize: sanitizeHTML,
|
||||
_ensureDOMPurifyLoaded: () => window.htmlSanitizer ? window.htmlSanitizer.ensureDOMPurifyLoaded() : Promise.resolve(null)
|
||||
};
|
||||
|
||||
// Expose globally
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
||||
window.app = window.app || {};
|
||||
|
||||
const CORRELATION_HEADER = 'X-Correlation-ID';
|
||||
let warnedTokenStorage = false;
|
||||
let warnedDeprecatedPatch = false;
|
||||
|
||||
function generateCorrelationId() {
|
||||
try {
|
||||
@@ -34,6 +36,8 @@
|
||||
async function wrappedFetch(resource, options = {}) {
|
||||
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
|
||||
const headers = normalizeHeaders(options.headers);
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const body = options.body;
|
||||
|
||||
// Inject correlation id if not present
|
||||
let outgoingCid = headers.get(CORRELATION_HEADER);
|
||||
@@ -48,10 +52,30 @@
|
||||
if (storedToken && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${storedToken}`);
|
||||
}
|
||||
// One-time security note if we detect token in localStorage
|
||||
if (!warnedTokenStorage && typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')) {
|
||||
warnedTokenStorage = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Security note: auth tokens are read from localStorage. If this app is exposed to the internet, migrate to HttpOnly cookies.');
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore storage access errors (e.g., privacy mode)
|
||||
}
|
||||
|
||||
// Inject Content-Type: application/json for JSON string bodies when missing
|
||||
try {
|
||||
const hasContentType = headers.has('Content-Type');
|
||||
const methodAllowsBody = method !== 'GET' && method !== 'HEAD';
|
||||
if (methodAllowsBody && body != null && !hasContentType) {
|
||||
// Only auto-set for stringified JSON bodies to avoid interfering with FormData or other types
|
||||
if (typeof body === 'string') {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort only; ignore header normalization errors
|
||||
}
|
||||
|
||||
const requestInit = { ...options, headers };
|
||||
|
||||
const response = await originalFetch(resource, requestInit);
|
||||
@@ -132,10 +156,18 @@
|
||||
parseErrorEnvelope,
|
||||
toError,
|
||||
formatAlert,
|
||||
wrappedFetch,
|
||||
};
|
||||
|
||||
// Install wrapper
|
||||
window.fetch = wrappedFetch;
|
||||
// Install wrapper (deprecated). Keep for backward compatibility, but nudge callers to use window.http.wrappedFetch
|
||||
window.fetch = async function(...args) {
|
||||
if (!warnedDeprecatedPatch) {
|
||||
warnedDeprecatedPatch = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Deprecated: global fetch() is wrapped. Prefer window.http.wrappedFetch for clarity and testability.');
|
||||
}
|
||||
return wrappedFetch(...args);
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -54,12 +54,8 @@ async function saveThemePreference(theme) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
await fetch('/api/auth/theme-preference', {
|
||||
await window.http.wrappedFetch('/api/auth/theme-preference', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme_preference: theme })
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -88,9 +84,7 @@ async function loadUserThemePreference() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
if (user.theme_preference) {
|
||||
@@ -162,13 +156,8 @@ function validateField(field) {
|
||||
function setupAPIHelpers() {
|
||||
// Set up default headers for all API calls
|
||||
window.apiHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (app.token) {
|
||||
window.apiHeaders['Authorization'] = `Bearer ${app.token}`;
|
||||
}
|
||||
|
||||
// Start proactive refresh scheduling when a token is present
|
||||
if (app.token) {
|
||||
@@ -197,7 +186,7 @@ async function apiCall(url, options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetch(url, config);
|
||||
let response = await window.http.wrappedFetch(url, config);
|
||||
const updateCorrelationFromResponse = (resp) => {
|
||||
try {
|
||||
const cid = resp && resp.headers ? resp.headers.get('X-Correlation-ID') : null;
|
||||
@@ -215,7 +204,7 @@ async function apiCall(url, options = {}) {
|
||||
headers: { ...window.apiHeaders, ...options.headers },
|
||||
...options
|
||||
};
|
||||
response = await fetch(url, retryConfig);
|
||||
response = await window.http.wrappedFetch(url, retryConfig);
|
||||
lastCorrelationId = updateCorrelationFromResponse(response);
|
||||
} catch (_) {
|
||||
// fall through to logout below
|
||||
@@ -269,7 +258,6 @@ function setAuthTokens(accessToken, newRefreshToken = null) {
|
||||
if (accessToken) {
|
||||
app.token = accessToken;
|
||||
localStorage.setItem('auth_token', accessToken);
|
||||
window.apiHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (newRefreshToken) {
|
||||
app.refreshToken = newRefreshToken;
|
||||
@@ -297,9 +285,7 @@ async function checkTokenValidity() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return false;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (!response.ok) {
|
||||
// Invalid token
|
||||
return false;
|
||||
@@ -334,9 +320,7 @@ async function checkUserPermissions() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
app.user = user;
|
||||
@@ -361,9 +345,7 @@ async function getInactivityWarningMinutes() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return 240;
|
||||
try {
|
||||
const resp = await fetch('/api/settings/inactivity_warning_minutes', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const resp = await window.http.wrappedFetch('/api/settings/inactivity_warning_minutes');
|
||||
if (!resp.ok) return 240;
|
||||
const data = await resp.json();
|
||||
if (typeof data.minutes === 'number') return data.minutes;
|
||||
@@ -512,9 +494,8 @@ async function logout(reason = null) {
|
||||
const rtoken = localStorage.getItem('refresh_token');
|
||||
try {
|
||||
if (rtoken) {
|
||||
await fetch('/api/auth/logout', {
|
||||
await window.http.wrappedFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rtoken })
|
||||
});
|
||||
}
|
||||
@@ -531,7 +512,7 @@ async function logout(reason = null) {
|
||||
app.user = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete window.apiHeaders['Authorization'];
|
||||
// Authorization header is injected by fetch wrapper; nothing to clean here
|
||||
|
||||
if (reason) {
|
||||
try { sessionStorage.setItem('logout_reason', reason); } catch (_) {}
|
||||
@@ -702,7 +683,7 @@ function displaySearchResults(container, results) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsHtml = results.map(result => `
|
||||
const resultsHtmlRaw = results.map(result => `
|
||||
<div class="search-result p-2 border-bottom">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
@@ -713,8 +694,11 @@ function displaySearchResults(container, results) {
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = resultsHtml;
|
||||
if (window.setSafeHTML) {
|
||||
window.setSafeHTML(container, resultsHtmlRaw);
|
||||
} else {
|
||||
container.innerHTML = resultsHtmlRaw;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
@@ -814,13 +798,9 @@ function setupGlobalErrorHandlers() {
|
||||
|
||||
async function postClientError(payload) {
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||
const token = (window.app && window.app.token) || localStorage.getItem('auth_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
// Fire-and-forget; do not block UI
|
||||
fetch('/api/documents/client-error', {
|
||||
window.http.wrappedFetch('/api/documents/client-error', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
@@ -875,11 +855,10 @@ async function refreshToken() {
|
||||
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
||||
app.refreshInProgress = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
|
||||
91
static/js/sanitizer.js
Normal file
91
static/js/sanitizer.js
Normal file
@@ -0,0 +1,91 @@
|
||||
(function () {
|
||||
const DOMPURIFY_CDN = 'https://cdn.jsdelivr.net/npm/dompurify@3.0.4/dist/purify.min.js';
|
||||
let _domPurifyPromise = null;
|
||||
|
||||
function ensureDOMPurifyLoaded() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {
|
||||
return Promise.resolve(window.DOMPurify);
|
||||
}
|
||||
if (_domPurifyPromise) return _domPurifyPromise;
|
||||
|
||||
_domPurifyPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
const script = document.createElement('script');
|
||||
script.src = DOMPURIFY_CDN;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
if (window.DOMPurify && window.DOMPurify.sanitize) {
|
||||
resolve(window.DOMPurify);
|
||||
} else {
|
||||
reject(new Error('DOMPurify failed to load'));
|
||||
}
|
||||
};
|
||||
script.onerror = () => reject(new Error('Failed to load DOMPurify'));
|
||||
document.head.appendChild(script);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
return _domPurifyPromise;
|
||||
}
|
||||
|
||||
// Basic fallback sanitizer when DOMPurify is not available yet.
|
||||
function fallbackSanitize(dirty) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = dirty;
|
||||
|
||||
// Remove script and style tags
|
||||
temp.querySelectorAll('script, style').forEach((el) => el.remove());
|
||||
|
||||
// Remove dangerous attributes
|
||||
temp.querySelectorAll('*').forEach((el) => {
|
||||
Array.from(el.attributes).forEach((attr) => {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (/^on/i.test(name)) {
|
||||
el.removeAttribute(name);
|
||||
return;
|
||||
}
|
||||
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
|
||||
el.removeAttribute(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeHTML(dirty) {
|
||||
if (typeof window !== 'undefined' && window.DOMPurify && window.DOMPurify.sanitize) {
|
||||
return window.DOMPurify.sanitize(dirty);
|
||||
}
|
||||
// Trigger async load so the next call benefits
|
||||
ensureDOMPurifyLoaded().catch(() => {});
|
||||
return fallbackSanitize(dirty);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = String(text == null ? '' : text);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
function setSafeHTML(element, html) {
|
||||
if (!element) return;
|
||||
const sanitized = sanitizeHTML(String(html == null ? '' : html));
|
||||
element.innerHTML = sanitized;
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.htmlSanitizer = {
|
||||
sanitize: sanitizeHTML,
|
||||
ensureDOMPurifyLoaded,
|
||||
escape: escapeHtml,
|
||||
setHTML: setSafeHTML
|
||||
};
|
||||
window.setSafeHTML = setSafeHTML;
|
||||
})();
|
||||
Reference in New Issue
Block a user