Skip to content

Commit 65cca78

Browse files
committed
support more cross origin images
1 parent d00e353 commit 65cca78

7 files changed

Lines changed: 567 additions & 10 deletions

File tree

manifest.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"scripting",
2525
"contextMenus"
2626
],
27+
"host_permissions": [
28+
"<all_urls>"
29+
],
2730
"background": {
2831
"service_worker": "service-worker.js"
2932
}

src/background/service-worker.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
7474
return;
7575
}
7676

77+
if (message.type === 'fetch-image-data-url') {
78+
void fetchImageDataUrl(message)
79+
.then((response) => sendResponse(response))
80+
.catch((error) => sendResponse({ ok: false, error: error.message }));
81+
return true;
82+
}
83+
7784
if (message.type === 'export-transfer-start') {
7885
sendResponse(handleTransferStart(message, sender));
7986
return;
@@ -234,6 +241,29 @@ async function handleResultBlob(blob, filename) {
234241
}
235242
}
236243

244+
async function fetchImageDataUrl(message) {
245+
if (typeof message?.url !== 'string' || message.url.length === 0) {
246+
return { ok: false, error: 'Missing image URL' };
247+
}
248+
249+
try {
250+
const response = await fetch(message.url, {
251+
credentials: 'include',
252+
redirect: 'follow',
253+
});
254+
255+
if (!response.ok) {
256+
throw new Error(`Image request failed with status ${response.status}`);
257+
}
258+
259+
const blob = await response.blob();
260+
const dataUrl = await blobToImageDataUrl(blob, message.url);
261+
return { ok: true, dataUrl };
262+
} catch (error) {
263+
return { ok: false, error: error?.message ?? String(error) };
264+
}
265+
}
266+
237267
async function createDownloadTargetFromBlob(blob) {
238268
if (shouldUseObjectUrlForBlobDownload()) {
239269
const objectUrl = URL.createObjectURL(blob);
@@ -289,6 +319,17 @@ async function blobToDataUrl(blob) {
289319
return `data:${blob.type || 'application/octet-stream'};base64,${base64}`;
290320
}
291321

322+
async function blobToImageDataUrl(blob, sourceUrl) {
323+
const bytes = new Uint8Array(await blob.arrayBuffer());
324+
const mime = detectImageMimeType(bytes, blob.type, sourceUrl);
325+
326+
if (!mime) {
327+
throw new Error('Fetched resource was not a supported image');
328+
}
329+
330+
return `data:${mime};base64,${bytesToBase64(bytes)}`;
331+
}
332+
292333
function bytesToBase64(bytes) {
293334
const chunkSize = 0x8000;
294335
let binary = '';
@@ -301,6 +342,57 @@ function bytesToBase64(bytes) {
301342
return btoa(binary);
302343
}
303344

345+
function detectImageMimeType(bytes, declaredType, sourceUrl) {
346+
const normalizedDeclaredType = normalizeMimeType(declaredType);
347+
if (normalizedDeclaredType?.startsWith('image/')) {
348+
return normalizedDeclaredType;
349+
}
350+
351+
if (looksLikeSvgPayload(bytes) || looksLikeSvgUrl(sourceUrl)) {
352+
return 'image/svg+xml';
353+
}
354+
355+
if (matchesSignature(bytes, [0x89, 0x50, 0x4E, 0x47])) return 'image/png';
356+
if (matchesSignature(bytes, [0xFF, 0xD8, 0xFF])) return 'image/jpeg';
357+
if (matchesSignature(bytes, [0x47, 0x49, 0x46, 0x38])) return 'image/gif';
358+
if (matchesSignature(bytes, [0x42, 0x4D])) return 'image/bmp';
359+
if (matchesSignature(bytes, [0x52, 0x49, 0x46, 0x46]) && matchesSignature(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) {
360+
return 'image/webp';
361+
}
362+
363+
return null;
364+
}
365+
366+
function normalizeMimeType(mime) {
367+
if (typeof mime !== 'string' || mime.length === 0) return null;
368+
return mime.split(';', 1)[0].trim().toLowerCase() || null;
369+
}
370+
371+
function matchesSignature(bytes, signature) {
372+
if (bytes.length < signature.length) return false;
373+
374+
for (let index = 0; index < signature.length; index += 1) {
375+
if (bytes[index] !== signature[index]) return false;
376+
}
377+
378+
return true;
379+
}
380+
381+
function looksLikeSvgPayload(bytes) {
382+
if (bytes.length === 0) return false;
383+
384+
const sample = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 2048)));
385+
return sample.replace(/^\uFEFF/, '').trimStart().startsWith('<svg');
386+
}
387+
388+
function looksLikeSvgUrl(sourceUrl) {
389+
try {
390+
return new URL(sourceUrl).pathname.toLowerCase().endsWith('.svg');
391+
} catch {
392+
return false;
393+
}
394+
}
395+
304396
function decodeExportChunk(chunkBase64) {
305397
if (typeof chunkBase64 !== 'string' || chunkBase64.length === 0) {
306398
throw new Error('Invalid export chunk payload');

src/content/export-utils.js

Lines changed: 199 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,216 @@ export function createExportBlob(data, mime) {
33
return new Blob([bytes], { type: mime });
44
}
55

6+
export function collectPotentiallyTaintedImageDiagnostics(irNodes, options = {}) {
7+
const normalizedOptions = getImageSourceOptions(options);
8+
9+
return irNodes.flatMap((node) => {
10+
if (node?.type !== 'image') return [];
11+
if (isLikelyCanvasSafeImageSource(node.dataUrl, normalizedOptions)) return [];
12+
13+
const classification = classifyCanvasImageSource(node.dataUrl, normalizedOptions);
14+
return [{
15+
source: node.dataUrl,
16+
resolvedUrl: classification.resolvedUrl,
17+
origin: classification.origin,
18+
pageOrigin: classification.pageOrigin,
19+
classification: classification.classification,
20+
sourceMetadata: node.source ?? null,
21+
originalType: node.source?.originalType ?? null,
22+
xpath: node.source?.xpath ?? null,
23+
}];
24+
});
25+
}
26+
27+
export function collectInaccessibleIframeDiagnostics(root = globalThis.document) {
28+
const diagnostics = [];
29+
const visitedContainers = new Set();
30+
31+
function visit(container) {
32+
if (!container || visitedContainers.has(container) || typeof container.querySelectorAll !== 'function') {
33+
return;
34+
}
35+
36+
visitedContainers.add(container);
37+
38+
for (const element of Array.from(container.querySelectorAll('*'))) {
39+
if (element?.shadowRoot) {
40+
visit(element.shadowRoot);
41+
}
42+
43+
if (!isIframeElement(element)) continue;
44+
45+
try {
46+
const frameDocument = element.contentDocument;
47+
if (!frameDocument) {
48+
diagnostics.push({
49+
src: normalizeOptionalString(element.src),
50+
title: normalizeOptionalString(element.title),
51+
reason: 'iframe-not-loaded',
52+
errorName: null,
53+
});
54+
continue;
55+
}
56+
57+
visit(frameDocument);
58+
} catch (error) {
59+
diagnostics.push({
60+
src: normalizeOptionalString(element.src),
61+
title: normalizeOptionalString(element.title),
62+
reason: error?.name === 'SecurityError' ? 'cross-origin-iframe' : 'iframe-access-error',
63+
errorName: normalizeOptionalString(error?.name),
64+
});
65+
}
66+
}
67+
}
68+
69+
visit(root);
70+
return diagnostics;
71+
}
72+
673
export function stripPotentiallyTaintedImages(irNodes, options = {}) {
7-
const baseUrl = options.baseUrl ?? globalThis.document?.baseURI ?? globalThis.location?.href;
8-
const pageOrigin = options.pageOrigin ?? globalThis.location?.origin ?? null;
74+
const { baseUrl, pageOrigin } = getImageSourceOptions(options);
975

1076
return irNodes.filter((node) => {
1177
if (node?.type !== 'image') return true;
1278
return isLikelyCanvasSafeImageSource(node.dataUrl, { baseUrl, pageOrigin });
1379
});
1480
}
1581

82+
export async function replaceUnsafeImageSources(irNodes, resolver, options = {}) {
83+
if (typeof resolver !== 'function') return irNodes;
84+
85+
const normalizedOptions = getImageSourceOptions(options);
86+
const unsafeSources = [...new Set(irNodes
87+
.filter((node) => node?.type === 'image')
88+
.map((node) => node.dataUrl)
89+
.filter((source) => !isLikelyCanvasSafeImageSource(source, normalizedOptions)))];
90+
91+
if (unsafeSources.length === 0) return irNodes;
92+
93+
const replacements = new Map();
94+
95+
await Promise.all(unsafeSources.map(async (source) => {
96+
try {
97+
const resolvedSource = await resolver(source);
98+
if (typeof resolvedSource === 'string' && resolvedSource.startsWith('data:image/')) {
99+
replacements.set(source, resolvedSource);
100+
}
101+
} catch {
102+
// Ignore failures and fall back to the original URL.
103+
}
104+
}));
105+
106+
if (replacements.size === 0) return irNodes;
107+
108+
return irNodes.map((node) => {
109+
if (node?.type !== 'image') return node;
110+
const replacement = replacements.get(node.dataUrl);
111+
return replacement ? { ...node, dataUrl: replacement } : node;
112+
});
113+
}
114+
16115
export function isLikelyCanvasSafeImageSource(source, options = {}) {
17-
if (typeof source !== 'string' || source.length === 0) return false;
18-
if (source.startsWith('data:') || source.startsWith('blob:')) return true;
116+
const { classification } = classifyCanvasImageSource(source, options);
117+
return classification === 'data-url' ||
118+
classification === 'blob-url' ||
119+
classification === 'same-origin-url';
120+
}
121+
122+
export function classifyCanvasImageSource(source, options = {}) {
123+
const normalizedOptions = getImageSourceOptions(options);
124+
125+
if (typeof source !== 'string' || source.length === 0) {
126+
return {
127+
classification: 'missing-source',
128+
resolvedUrl: null,
129+
origin: null,
130+
pageOrigin: normalizedOptions.pageOrigin,
131+
};
132+
}
133+
134+
if (source.startsWith('data:')) {
135+
return {
136+
classification: 'data-url',
137+
resolvedUrl: source,
138+
origin: null,
139+
pageOrigin: normalizedOptions.pageOrigin,
140+
};
141+
}
142+
143+
if (source.startsWith('blob:')) {
144+
return {
145+
classification: 'blob-url',
146+
resolvedUrl: source,
147+
origin: null,
148+
pageOrigin: normalizedOptions.pageOrigin,
149+
};
150+
}
151+
152+
const url = resolveImageSourceUrl(source, normalizedOptions.baseUrl);
153+
if (!url) {
154+
return {
155+
classification: 'invalid-url',
156+
resolvedUrl: null,
157+
origin: null,
158+
pageOrigin: normalizedOptions.pageOrigin,
159+
};
160+
}
161+
162+
if (url.protocol === 'file:') {
163+
return {
164+
classification: 'file-url',
165+
resolvedUrl: url.href,
166+
origin: null,
167+
pageOrigin: normalizedOptions.pageOrigin,
168+
};
169+
}
19170

171+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
172+
return {
173+
classification: 'other-url-scheme',
174+
resolvedUrl: url.href,
175+
origin: url.origin === 'null' ? null : url.origin,
176+
pageOrigin: normalizedOptions.pageOrigin,
177+
};
178+
}
179+
180+
if (!normalizedOptions.pageOrigin) {
181+
return {
182+
classification: 'network-url-no-page-origin',
183+
resolvedUrl: url.href,
184+
origin: url.origin,
185+
pageOrigin: null,
186+
};
187+
}
188+
189+
return {
190+
classification: url.origin === normalizedOptions.pageOrigin ? 'same-origin-url' : 'cross-origin-url',
191+
resolvedUrl: url.href,
192+
origin: url.origin,
193+
pageOrigin: normalizedOptions.pageOrigin,
194+
};
195+
}
196+
197+
function getImageSourceOptions(options = {}) {
198+
return {
199+
baseUrl: options.baseUrl ?? globalThis.document?.baseURI ?? globalThis.location?.href,
200+
pageOrigin: options.pageOrigin ?? globalThis.location?.origin ?? null,
201+
};
202+
}
203+
204+
function resolveImageSourceUrl(source, baseUrl) {
20205
try {
21-
const url = new URL(source, options.baseUrl);
22-
if (!options.pageOrigin) return false;
23-
return url.origin === options.pageOrigin;
206+
return new URL(source, baseUrl);
24207
} catch {
25-
return false;
208+
return null;
26209
}
27210
}
211+
212+
function normalizeOptionalString(value) {
213+
return typeof value === 'string' && value.length > 0 ? value : null;
214+
}
215+
216+
function isIframeElement(element) {
217+
return typeof element?.tagName === 'string' && element.tagName.toLowerCase() === 'iframe';
218+
}

0 commit comments

Comments
 (0)