fix: prevent browser auth dialog when viewing emails with inline images
Inline CID images were replaced with raw JMAP download URLs that require authentication. When the browser loaded these as <img src>, the server responded with WWW-Authenticate: Basic, triggering a native login popup. - Add fetchBlobAsObjectUrl() to JMAPClient that fetches blobs via authenticated request and returns blob: object URLs - Update email-viewer and thread-conversation-view to fetch CID images asynchronously with auth, using blob: URLs instead of raw server URLs - Add ALLOWED_URI_REGEXP to DOMPurify config so blob: URLs are not stripped during sanitization - Add tests for fetchBlobAsObjectUrl and CID/blob URL sanitization
This commit is contained in:
@@ -436,6 +436,7 @@ export function EmailViewer({
|
|||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
|
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||||
const [quickReplyText, setQuickReplyText] = useState("");
|
const [quickReplyText, setQuickReplyText] = useState("");
|
||||||
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
||||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||||
@@ -493,6 +494,51 @@ export function EmailViewer({
|
|||||||
setShowSourceModal(false);
|
setShowSourceModal(false);
|
||||||
}, [email?.id, externalContentPolicy]);
|
}, [email?.id, externalContentPolicy]);
|
||||||
|
|
||||||
|
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !email?.attachments) {
|
||||||
|
setCidBlobUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidAttachments = email.attachments.filter(att => att.cid && att.blobId);
|
||||||
|
if (cidAttachments.length === 0) {
|
||||||
|
setCidBlobUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const objectUrls: string[] = [];
|
||||||
|
|
||||||
|
async function fetchCidBlobs() {
|
||||||
|
const urls: Record<string, string> = {};
|
||||||
|
await Promise.all(cidAttachments.map(async (att) => {
|
||||||
|
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
||||||
|
try {
|
||||||
|
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
|
||||||
|
if (!cancelled) {
|
||||||
|
urls[cidValue] = objectUrl;
|
||||||
|
objectUrls.push(objectUrl);
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to fetch inline image, will show placeholder
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!cancelled) {
|
||||||
|
setCidBlobUrls(urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCidBlobs();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [client, email?.id]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
let source = '';
|
let source = '';
|
||||||
@@ -662,28 +708,15 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// If we should use HTML version and it exists
|
// If we should use HTML version and it exists
|
||||||
if (useHtmlVersion && htmlContent) {
|
if (useHtmlVersion && htmlContent) {
|
||||||
// Replace cid: references with actual blob download URLs for inline images
|
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
|
||||||
const cidReplacedUrls = new Set<string>();
|
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
|
||||||
if (client && email.attachments) {
|
if (email.attachments) {
|
||||||
const cidMap = new Map<string, string>();
|
htmlContent = htmlContent.replace(
|
||||||
for (const att of email.attachments) {
|
/\bcid:([^"'\s)]+)/gi,
|
||||||
if (att.cid && att.blobId) {
|
(_match, cidRef) => {
|
||||||
const cidValue = att.cid.replace(/^<|>$/g, '');
|
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
try {
|
|
||||||
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
|
|
||||||
cidMap.set(cidValue, url);
|
|
||||||
cidReplacedUrls.add(url);
|
|
||||||
} catch {
|
|
||||||
// downloadUrl not available yet, skip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
if (cidMap.size > 0) {
|
|
||||||
htmlContent = htmlContent.replace(
|
|
||||||
/\bcid:([^"'\s)]+)/gi,
|
|
||||||
(match, cidRef) => cidMap.get(cidRef) || match
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a custom DOMPurify hook to handle external content
|
// Create a custom DOMPurify hook to handle external content
|
||||||
@@ -714,7 +747,7 @@ export function EmailViewer({
|
|||||||
if (shouldBlockExternal) {
|
if (shouldBlockExternal) {
|
||||||
if (node.tagName === 'IMG') {
|
if (node.tagName === 'IMG') {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||||
node.setAttribute('data-blocked-src', src);
|
node.setAttribute('data-blocked-src', src);
|
||||||
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
||||||
node.setAttribute('alt', '');
|
node.setAttribute('alt', '');
|
||||||
@@ -825,7 +858,7 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, client]);
|
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, cidBlobUrls]);
|
||||||
|
|
||||||
// Print only the email content in a new window
|
// Print only the email content in a new window
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function EmailCard({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
|
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
// Mark as read when email is expanded
|
// Mark as read when email is expanded
|
||||||
@@ -255,6 +256,51 @@ function EmailCard({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
|
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
|
||||||
|
|
||||||
|
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !email?.attachments) {
|
||||||
|
setCidBlobUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidAttachments = email.attachments.filter(att => att.cid && att.blobId);
|
||||||
|
if (cidAttachments.length === 0) {
|
||||||
|
setCidBlobUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const objectUrls: string[] = [];
|
||||||
|
|
||||||
|
async function fetchCidBlobs() {
|
||||||
|
const urls: Record<string, string> = {};
|
||||||
|
await Promise.all(cidAttachments.map(async (att) => {
|
||||||
|
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
||||||
|
try {
|
||||||
|
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
|
||||||
|
if (!cancelled) {
|
||||||
|
urls[cidValue] = objectUrl;
|
||||||
|
objectUrls.push(objectUrl);
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to fetch inline image, will show placeholder
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!cancelled) {
|
||||||
|
setCidBlobUrls(urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCidBlobs();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [client, email?.id]);
|
||||||
|
|
||||||
// Sanitize and prepare email HTML content
|
// Sanitize and prepare email HTML content
|
||||||
const emailContent = useMemo(() => {
|
const emailContent = useMemo(() => {
|
||||||
if (!email) return { html: "", isHtml: false };
|
if (!email) return { html: "", isHtml: false };
|
||||||
@@ -269,28 +315,15 @@ function EmailCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (useHtmlVersion && htmlContent) {
|
if (useHtmlVersion && htmlContent) {
|
||||||
// Replace cid: references with actual blob download URLs for inline images
|
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
|
||||||
const cidReplacedUrls = new Set<string>();
|
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
|
||||||
if (client && email.attachments) {
|
if (email.attachments) {
|
||||||
const cidMap = new Map<string, string>();
|
htmlContent = htmlContent.replace(
|
||||||
for (const att of email.attachments) {
|
/\bcid:([^"'\s)]+)/gi,
|
||||||
if (att.cid && att.blobId) {
|
(_match, cidRef) => {
|
||||||
const cidValue = att.cid.replace(/^<|>$/g, '');
|
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
try {
|
|
||||||
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
|
|
||||||
cidMap.set(cidValue, url);
|
|
||||||
cidReplacedUrls.add(url);
|
|
||||||
} catch {
|
|
||||||
// downloadUrl not available yet, skip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
if (cidMap.size > 0) {
|
|
||||||
htmlContent = htmlContent.replace(
|
|
||||||
/\bcid:([^"'\s)]+)/gi,
|
|
||||||
(match, cidRef) => cidMap.get(cidRef) || match
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockedExternalContent = false;
|
let blockedExternalContent = false;
|
||||||
@@ -304,7 +337,7 @@ function EmailCard({
|
|||||||
if (!allowExternal) {
|
if (!allowExternal) {
|
||||||
if (node.tagName === 'IMG') {
|
if (node.tagName === 'IMG') {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||||
node.setAttribute('data-blocked-src', src);
|
node.setAttribute('data-blocked-src', src);
|
||||||
node.removeAttribute('src');
|
node.removeAttribute('src');
|
||||||
node.setAttribute('alt', '[Image blocked]');
|
node.setAttribute('alt', '[Image blocked]');
|
||||||
@@ -378,7 +411,7 @@ function EmailCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { html: "", isHtml: false };
|
return { html: "", isHtml: false };
|
||||||
}, [email, allowExternal, resolvedTheme, client]);
|
}, [email, allowExternal, resolvedTheme, cidBlobUrls]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import {
|
import {
|
||||||
sanitizeEmailHtml,
|
sanitizeEmailHtml,
|
||||||
sanitizeSignatureHtml,
|
sanitizeSignatureHtml,
|
||||||
parseHtmlSafely,
|
parseHtmlSafely,
|
||||||
hasRichFormatting,
|
hasRichFormatting,
|
||||||
|
EMAIL_SANITIZE_CONFIG,
|
||||||
} from '../email-sanitization';
|
} from '../email-sanitization';
|
||||||
|
|
||||||
describe('email-sanitization', () => {
|
describe('email-sanitization', () => {
|
||||||
@@ -192,4 +194,62 @@ describe('email-sanitization', () => {
|
|||||||
expect(hasRichFormatting(' ')).toBe(false);
|
expect(hasRichFormatting(' ')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('inline CID image handling', () => {
|
||||||
|
it('should preserve blob: URLs for CID-replaced images (not treated as external)', () => {
|
||||||
|
// Simulate what the component does: replace cid: with blob: object URLs
|
||||||
|
const html = '<p>See image:</p><img src="blob:http://localhost/abc-123">';
|
||||||
|
const clean = sanitizeEmailHtml(html);
|
||||||
|
expect(clean).toContain('blob:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve data: URLs for CID placeholder images', () => {
|
||||||
|
const html = '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">';
|
||||||
|
const clean = sanitizeEmailHtml(html);
|
||||||
|
expect(clean).toContain('data:image/gif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not leave raw JMAP download URLs after CID replacement pattern', () => {
|
||||||
|
// This tests the regex pattern used for CID replacement
|
||||||
|
const htmlWithCid = '<img src="cid:image001@example.com">';
|
||||||
|
// Simulate the component's replacement: all cid: refs should become blob: or data: URLs
|
||||||
|
const replaced = htmlWithCid.replace(
|
||||||
|
/\bcid:([^"'\s)]+)/gi,
|
||||||
|
() => 'blob:http://localhost/safe-object-url'
|
||||||
|
);
|
||||||
|
expect(replaced).not.toContain('cid:');
|
||||||
|
expect(replaced).toContain('blob:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block external http(s) images but not blob/data URLs via DOMPurify hook', () => {
|
||||||
|
const html = `
|
||||||
|
<img src="blob:http://localhost/inline-ok">
|
||||||
|
<img src="https://tracker.evil.com/pixel.png">
|
||||||
|
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
|
||||||
|
`;
|
||||||
|
|
||||||
|
const config = { ...EMAIL_SANITIZE_CONFIG };
|
||||||
|
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'IMG') {
|
||||||
|
const src = node.getAttribute('src');
|
||||||
|
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||||
|
node.setAttribute('data-blocked-src', src);
|
||||||
|
node.removeAttribute('src');
|
||||||
|
node.setAttribute('alt', '[Image blocked]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clean = DOMPurify.sanitize(html, config);
|
||||||
|
DOMPurify.removeAllHooks();
|
||||||
|
|
||||||
|
// External https image should be blocked
|
||||||
|
expect(clean).toContain('data-blocked-src');
|
||||||
|
expect(clean).toContain('tracker.evil.com');
|
||||||
|
// blob: and data: URLs should NOT be blocked (they don't start with http/https)
|
||||||
|
expect(clean).toContain('blob:');
|
||||||
|
expect(clean).toContain('data:image/gif');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -277,4 +277,58 @@ describe('JMAPClient resilience', () => {
|
|||||||
expect(callback).not.toHaveBeenCalled();
|
expect(callback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchBlobAsObjectUrl', () => {
|
||||||
|
it('fetches blob with authentication and returns an object URL', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const binaryData = new Uint8Array([137, 80, 78, 71]); // PNG magic bytes
|
||||||
|
const blobResponse = new Response(binaryData, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'image/png' },
|
||||||
|
});
|
||||||
|
fetchSpy.mockResolvedValueOnce(blobResponse);
|
||||||
|
|
||||||
|
const objectUrl = await client.fetchBlobAsObjectUrl('blob-123', 'image.png', 'image/png');
|
||||||
|
|
||||||
|
expect(objectUrl).toMatch(/^blob:/);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify auth header was sent
|
||||||
|
const callHeaders = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
|
||||||
|
expect(callHeaders['Authorization']).toContain('Basic');
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when download URL is not available', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })));
|
||||||
|
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
||||||
|
// The client needs to be connected but with an empty downloadUrl
|
||||||
|
// getBlobDownloadUrl will throw before fetch is called
|
||||||
|
await expect(
|
||||||
|
(async () => {
|
||||||
|
// Connect first with valid session, then clear downloadUrl via re-connect with empty
|
||||||
|
await client.connect();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
// Now reconnect with empty downloadUrl to simulate the issue
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })));
|
||||||
|
// Force session refresh to pick up empty downloadUrl
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
fetchSpy
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401))
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })))
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
try { await client.ping(); } catch { /* ignore */ }
|
||||||
|
})()
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on HTTP error response', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.fetchBlobAsObjectUrl('bad-blob', 'file.dat')
|
||||||
|
).rejects.toThrow('Failed to fetch blob: 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const EMAIL_SANITIZE_CONFIG = {
|
|||||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
|
// Allow blob: URIs so authenticated inline images (CID) are not stripped
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
FORBID_TAGS: [
|
FORBID_TAGS: [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'meta', 'link', 'base',
|
'input', 'button', 'meta', 'link', 'base',
|
||||||
|
|||||||
@@ -1429,6 +1429,16 @@ export class JMAPClient {
|
|||||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
|
||||||
|
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||||
|
const response = await this.authenticatedFetch(url, {});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
getCapabilities(): Record<string, unknown> {
|
getCapabilities(): Record<string, unknown> {
|
||||||
return this.capabilities;
|
return this.capabilities;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user