@@ -40,18 +40,80 @@ const IMAGE_SIGNATURES: Array<{
4040 ( buffer . subarray ( 0 , 6 ) . toString ( "ascii" ) === "GIF87a" ||
4141 buffer . subarray ( 0 , 6 ) . toString ( "ascii" ) === "GIF89a" ) ,
4242 } ,
43+ {
44+ mime : "image/bmp" ,
45+ matches : ( buffer ) => buffer . length >= 2 && buffer [ 0 ] === 0x42 && buffer [ 1 ] === 0x4d ,
46+ } ,
4347] ;
4448
49+ const HEIC_BRANDS = new Set ( [ "heic" , "heix" , "hevc" , "hevx" , "heis" , "heim" , "hevm" , "hevs" ] ) ;
50+ const HEIF_BRANDS = new Set ( [ "mif1" , "msf1" ] ) ;
51+ const IMAGE_SIGNATURE_PREFIX_BASE64_CHARS = 128 ;
52+ const INLINE_IMAGE_DATA_URL_MIMES = new Set ( [ "image/png" , "image/jpeg" , "image/webp" , "image/gif" ] ) ;
53+
4554function startsWithDataUrl ( value : string ) : boolean {
4655 return (
4756 value . slice ( 0 , INLINE_IMAGE_DATA_URL_PREFIX . length ) . toLowerCase ( ) ===
4857 INLINE_IMAGE_DATA_URL_PREFIX
4958 ) ;
5059}
5160
61+ function sniffIsoBmffImageMime ( buffer : Buffer ) : string | undefined {
62+ if ( buffer . length < 12 || buffer . subarray ( 4 , 8 ) . toString ( "ascii" ) !== "ftyp" ) {
63+ return undefined ;
64+ }
65+ const brands = [ buffer . subarray ( 8 , 12 ) . toString ( "ascii" ) ] ;
66+ for ( let offset = 16 ; offset + 4 <= buffer . length ; offset += 4 ) {
67+ brands . push ( buffer . subarray ( offset , offset + 4 ) . toString ( "ascii" ) ) ;
68+ }
69+ if ( brands . some ( ( brand ) => HEIC_BRANDS . has ( brand ) ) ) {
70+ return "image/heic" ;
71+ }
72+ if ( brands . some ( ( brand ) => HEIF_BRANDS . has ( brand ) ) ) {
73+ return "image/heif" ;
74+ }
75+ return undefined ;
76+ }
77+
5278/** Sniffs supported inline image formats from decoded bytes. */
5379export function sniffInlineImageMime ( buffer : Buffer ) : string | undefined {
54- return IMAGE_SIGNATURES . find ( ( signature ) => signature . matches ( buffer ) ) ?. mime ;
80+ return (
81+ IMAGE_SIGNATURES . find ( ( signature ) => signature . matches ( buffer ) ) ?. mime ??
82+ sniffIsoBmffImageMime ( buffer )
83+ ) ;
84+ }
85+
86+ function isImageMimeType ( value : string ) : boolean {
87+ return value . trim ( ) . toLowerCase ( ) . startsWith ( "image/" ) ;
88+ }
89+
90+ export type SanitizedInlineImageBase64 = {
91+ mimeType : string ;
92+ base64 : string ;
93+ } ;
94+
95+ /** Canonicalizes trusted inline image base64 and rejects malformed or non-image payloads. */
96+ export function sanitizeInlineImageBase64 ( params : {
97+ mimeType : string ;
98+ base64 : string ;
99+ } ) : SanitizedInlineImageBase64 | undefined {
100+ if ( ! isImageMimeType ( params . mimeType ) ) {
101+ return undefined ;
102+ }
103+ const canonicalPayload = canonicalizeBase64 ( params . base64 ) ;
104+ if ( ! canonicalPayload ) {
105+ return undefined ;
106+ }
107+ const sniffedMimeType = sniffInlineImageMime (
108+ Buffer . from ( canonicalPayload . slice ( 0 , IMAGE_SIGNATURE_PREFIX_BASE64_CHARS ) , "base64" ) ,
109+ ) ;
110+ if ( ! sniffedMimeType ) {
111+ return undefined ;
112+ }
113+ return {
114+ mimeType : sniffedMimeType ,
115+ base64 : canonicalPayload ,
116+ } ;
55117}
56118
57119function parseInlineImageDataUrl ( value : string ) :
@@ -78,12 +140,17 @@ function parseInlineImageDataUrl(value: string):
78140
79141function metadataAllowsImageBase64 ( metadata : string [ ] ) : boolean {
80142 const [ mimeType , ...options ] = metadata ;
81- const isImageMimeType = mimeType !== undefined && mimeType . toLowerCase ( ) . startsWith ( "image/" ) ;
82- return isImageMimeType && options . some ( ( part ) => part . toLowerCase ( ) === "base64" ) ;
143+ return (
144+ mimeType !== undefined &&
145+ isImageMimeType ( mimeType ) &&
146+ options . some ( ( part ) => part . toLowerCase ( ) === "base64" )
147+ ) ;
83148}
84149
85- /** Canonicalizes trusted inline image data URLs and rejects malformed or non-image payloads. */
86- export function sanitizeInlineImageDataUrl ( imageUrl : string ) : string | undefined {
150+ function sanitizeInlineImageDataUrlWithAllowedMimes (
151+ imageUrl : string ,
152+ allowedMimes ?: Set < string > ,
153+ ) : string | undefined {
87154 const parsed = parseInlineImageDataUrl ( imageUrl ) ;
88155 if ( ! parsed ) {
89156 return undefined ;
@@ -95,14 +162,30 @@ export function sanitizeInlineImageDataUrl(imageUrl: string): string | undefined
95162 return undefined ;
96163 }
97164
98- const canonicalPayload = canonicalizeBase64 ( parsed . payload ) ;
99- if ( ! canonicalPayload ) {
165+ const [ mimeType ] = parsed . metadata ;
166+ const sanitized = sanitizeInlineImageBase64 ( {
167+ mimeType : mimeType ?? "" ,
168+ base64 : parsed . payload ,
169+ } ) ;
170+ if ( ! sanitized ) {
100171 return undefined ;
101172 }
102- const sniffedMimeType = sniffInlineImageMime ( Buffer . from ( canonicalPayload , "base64" ) ) ;
103- if ( ! sniffedMimeType ) {
173+ if ( allowedMimes && ! allowedMimes . has ( sanitized . mimeType ) ) {
104174 return undefined ;
105175 }
106176 // Trust the byte signature over caller-supplied metadata before reinlining.
107- return `data:${ sniffedMimeType } ;base64,${ canonicalPayload } ` ;
177+ return `data:${ sanitized . mimeType } ;base64,${ sanitized . base64 } ` ;
178+ }
179+
180+ /**
181+ * Canonicalizes trusted inline image data URLs for persistence.
182+ * Accepts every image signature supported by `sanitizeInlineImageBase64`.
183+ */
184+ export function sanitizeInlineImageDataUrlForStorage ( imageUrl : string ) : string | undefined {
185+ return sanitizeInlineImageDataUrlWithAllowedMimes ( imageUrl ) ;
186+ }
187+
188+ /** Canonicalizes provider-safe inline image data URLs and rejects unsupported formats. */
189+ export function sanitizeInlineImageDataUrl ( imageUrl : string ) : string | undefined {
190+ return sanitizeInlineImageDataUrlWithAllowedMimes ( imageUrl , INLINE_IMAGE_DATA_URL_MIMES ) ;
108191}
0 commit comments