@@ -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+
673export 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+
16115export 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