Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions third_party/muya/src/utils/__tests__/image.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof vi.fn>;
}

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, () => {
Expand Down
52 changes: 39 additions & 13 deletions third_party/muya/src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand All @@ -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 <img> 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<boolean | null> {
// 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 <img> 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;
}
}

Expand Down
Loading