From a4ea9871fb870aa8b594baa3dca9dbd272835dd8 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 14:04:06 -0700 Subject: [PATCH] Port upstream extensionless image content type fix --- .../muya/src/utils/__tests__/image.spec.ts | 104 +++++++++++++++++- third_party/muya/src/utils/image.ts | 52 ++++++--- 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/third_party/muya/src/utils/__tests__/image.spec.ts b/third_party/muya/src/utils/__tests__/image.spec.ts index 87fdc0f..6951e2b 100644 --- a/third_party/muya/src/utils/__tests__/image.spec.ts +++ b/third_party/muya/src/utils/__tests__/image.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it } from 'vitest'; -import { getImageSrc } from '../image'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkImageContentType, getImageSrc, loadImage } from '../image'; // Regression tests for the Phase G "G1" blocker: relative-path images stopped // rendering after the @muyajs/core migration because `getImageSrc` returned a @@ -27,6 +27,106 @@ afterEach(() => { window.DIRNAME = undefined; }); +describe('checkImageContentType (#3837)', () => { + const sameOrigin = (p: string) => new URL(p, window.location.href).href; + const CROSS_ORIGIN = 'https://img.shields.io/badge/x-blue'; + + function mockFetch(status: number, contentType: string | null) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status, + headers: { + get: (h: string) => + h.toLowerCase() === 'content-type' ? contentType : null, + }, + }), + ); + return globalThis.fetch as unknown as ReturnType; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('accepts a same-origin image type carrying a charset parameter', async () => { + mockFetch(200, 'image/svg+xml;charset=utf-8'); + expect(await checkImageContentType(sameOrigin('/badge'))).toBe(true); + }); + + it('accepts a bare same-origin image content type', async () => { + mockFetch(200, 'image/png'); + expect(await checkImageContentType(sameOrigin('/badge'))).toBe(true); + }); + + it('reports a same-origin non-image content type as false', async () => { + mockFetch(200, 'text/html;charset=utf-8'); + expect(await checkImageContentType(sameOrigin('/page'))).toBe(false); + }); + + it('returns null on a non-200 response', async () => { + mockFetch(404, 'image/png'); + expect(await checkImageContentType(sameOrigin('/missing'))).toBeNull(); + }); + + it('returns null when the same-origin HEAD fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + expect(await checkImageContentType(sameOrigin('/x'))).toBeNull(); + }); + + it('skips the HEAD entirely for a cross-origin URL', async () => { + const fetchSpy = mockFetch(200, 'image/png'); + expect(await checkImageContentType(CROSS_ORIGIN)).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('loadImage — undetermined content-type still attempts the load (#3837)', () => { + function stubImage(succeeds: boolean) { + class FakeImage { + width = 10; + height = 10; + onload: (() => void) | null = null; + onerror: ((err: unknown) => void) | null = null; + private _src = ''; + + get src(): string { + return this._src; + } + + set src(value: string) { + this._src = value; + queueMicrotask(() => + succeeds ? this.onload?.() : this.onerror?.(new Error('load failed')), + ); + } + } + vi.stubGlobal('Image', FakeImage); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const sameOrigin = (p: string) => new URL(p, window.location.href).href; + + it('loads a cross-origin extensionless image after the HEAD check is skipped', async () => { + stubImage(true); + await expect( + loadImage('https://img.shields.io/badge/example-blue', true), + ).resolves.toMatchObject({ width: 10, height: 10 }); + }); + + it('still rejects when a same-origin HEAD positively reports a non-image type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 200, headers: { get: () => 'text/html' } }), + ); + stubImage(true); + await expect(loadImage(sameOrigin('/page'), true)).rejects.toBe('not an image.'); + }); +}); + describe('getImageSrc — relative local image paths anchored to window.DIRNAME', () => { it('resolves a relative path against the document directory', () => { withDirname(DIRNAME, () => { diff --git a/third_party/muya/src/utils/image.ts b/third_party/muya/src/utils/image.ts index c6f0ff8..b596529 100644 --- a/third_party/muya/src/utils/image.ts +++ b/third_party/muya/src/utils/image.ts @@ -159,7 +159,10 @@ export async function loadImage(url: string, detectContentType = false): Promise }> { if (detectContentType) { const isImage = await checkImageContentType(url); - if (!isImage) + // Only bail out when we positively know it is NOT an image. `null` + // means we couldn't check (e.g. a cross-origin HEAD blocked by CSP); + // fall through to the actual load, which `img-src` permits (#3837). + if (isImage === false) // eslint-disable-next-line prefer-promise-reject-errors return Promise.reject('not an image.'); } @@ -181,23 +184,46 @@ export async function loadImage(url: string, detectContentType = false): Promise }); } -export async function checkImageContentType(url: string) { +// Only a same-origin URL can have its Content-Type read from the renderer: a +// cross-origin response has its headers stripped by CORS, and the app's CSP +// refuses the request outright. Relative/opaque URLs are treated as same-origin +// so the check is still attempted. +function isSameOrigin(url: string): boolean { + try { + return new URL(url, window.location.href).origin === window.location.origin; + } + catch { + return true; + } +} + +// Returns `true`/`false` when a HEAD response positively identifies the URL as +// an image (or not), or `null` when that can't be determined. A `null` must NOT +// be read as "not an image": the actual load is governed by the far more +// permissive `img-src`, so callers should still attempt it (#3837 — shields.io +// badges and other extensionless remote images). +export async function checkImageContentType(url: string): Promise { + // Don't fire a HEAD we could never read: a cross-origin request is refused + // by the CSP (logging a console error) and unreadable under CORS anyway. + // Report "undetermined" and let the caller fall through to the load. + if (!isSameOrigin(url)) + return null; + try { const res = await fetch(url, { method: 'HEAD' }); - const contentType = res.headers.get('content-type'); - - if ( - contentType - && res.status === 200 - && /^image\/(?:jpeg|png|gif|svg\+xml|webp)$/.test(contentType) - ) { - return true; - } + if (res.status !== 200) + return null; + + // Content-Type can carry parameters (e.g. `image/svg+xml;charset=utf-8`); + // match only the MIME type. + const contentType = res.headers.get('content-type')?.split(';')[0].trim(); + if (!contentType) + return null; - return false; + return /^image\/(?:jpeg|png|gif|svg\+xml|webp)$/.test(contentType); } catch { - return false; + return null; } }