Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

198 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest';
import {
detectMimeType,
isUnsafeMime,
resolveSafeMimeType,
UNSAFE_MIME_PREFIXES,
} from '../../src/utils/mime-guard';
vi.mock('file-type', async (importOriginal) => {
const actual = await importOriginal<typeof import('file-type')>();
return {
...actual,
fileTypeFromBuffer: vi.fn(async (buffer: Buffer) => {
if (buffer.toString('utf8') === 'mock-unsafe-magic-bytes') {
return { ext: 'js', mime: 'application/javascript' };
}
return actual.fileTypeFromBuffer(buffer);
}),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* A minimal valid 1×1 transparent PNG (67 bytes).
* Generated via `Buffer.from('<base64>', 'base64')` from a known-good PNG.
* This is enough for file-type to identify as image/png.
*/
const PNG_BUFFER = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
/** A real PDF magic bytes prefix (%%PDF — enough for file-type). */
const PDF_BYTES = Buffer.from('%PDF-1.4 1 0 obj<</Type/Catalog>>endobj', 'utf8');
/** HTML content — no magic bytes, text-based. */
const HTML_BYTES = Buffer.from(
'<!DOCTYPE html><html><body><script>alert(1)</script></body></html>',
'utf8'
);
/** SVG content — XML-based, no binary magic bytes. */
const SVG_BYTES = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
'utf8'
);
/** Plain-text CSV — no magic bytes. */
const CSV_BYTES = Buffer.from('id,name,email\n1,Alice,alice@example.com\n', 'utf8');
// ─── detectMimeType ────────────────────────────────────────────────────────────
describe('detectMimeType', () => {
it('correctly identifies a PNG file from magic bytes', async () => {
const result = await detectMimeType(PNG_BUFFER);
expect(result).toBe('image/png');
});
it('correctly identifies a PDF file from magic bytes', async () => {
const result = await detectMimeType(PDF_BYTES);
expect(result).toBe('application/pdf');
});
it('falls back to application/octet-stream for plain-text with no magic bytes', async () => {
// file-type cannot detect plain text, HTML, SVG — they have no binary signature
const result = await detectMimeType(HTML_BYTES);
expect(result).toBe('application/octet-stream');
});
it('falls back to application/octet-stream for an empty buffer', async () => {
const result = await detectMimeType(Buffer.alloc(0));
expect(result).toBe('application/octet-stream');
});
});
// ─── isUnsafeMime ─────────────────────────────────────────────────────────────
describe('isUnsafeMime', () => {
it('returns true for all entries in UNSAFE_MIME_PREFIXES', () => {
for (const prefix of UNSAFE_MIME_PREFIXES) {
expect(isUnsafeMime(prefix), `Expected ${prefix} to be unsafe`).toBe(true);
}
});
it('returns true for text/html with charset parameter', () => {
expect(isUnsafeMime('text/html; charset=utf-8')).toBe(true);
});
it('returns true for image/svg+xml', () => {
expect(isUnsafeMime('image/svg+xml')).toBe(true);
});
it('returns true for application/javascript', () => {
expect(isUnsafeMime('application/javascript')).toBe(true);
});
it('returns false for image/jpeg', () => {
expect(isUnsafeMime('image/jpeg')).toBe(false);
});
it('returns false for image/png', () => {
expect(isUnsafeMime('image/png')).toBe(false);
});
it('returns false for application/pdf', () => {
expect(isUnsafeMime('application/pdf')).toBe(false);
});
it('returns false for application/octet-stream', () => {
expect(isUnsafeMime('application/octet-stream')).toBe(false);
});
it('returns false for text/csv', () => {
expect(isUnsafeMime('text/csv')).toBe(false);
});
it('is case-insensitive', () => {
expect(isUnsafeMime('TEXT/HTML')).toBe(true);
expect(isUnsafeMime('Image/SVG+XML')).toBe(true);
});
});
// ─── resolveSafeMimeType ───────────────────────────────────────────────────────
describe('resolveSafeMimeType', () => {
it('returns image/png for a real PNG buffer, regardless of client claim', async () => {
// Client lies and says it is a text/html file
const result = await resolveSafeMimeType(PNG_BUFFER, 'text/html');
expect(result).toBe('image/png');
});
it('returns application/pdf for a real PDF buffer', async () => {
const result = await resolveSafeMimeType(PDF_BYTES, 'application/pdf');
expect(result).toBe('application/pdf');
});
it('returns application/octet-stream when 4KB content scan detects HTML tags in undetectable content', async () => {
// file-type cannot detect HTML from bytes — detection falls back to octet-stream.
// The 4KB tag scan detects the HTML payload and catches it.
const result = await resolveSafeMimeType(HTML_BYTES, 'text/html');
expect(result).toBe('application/octet-stream');
});
it('returns application/octet-stream when client claims image/svg+xml and file is undetectable', async () => {
const result = await resolveSafeMimeType(SVG_BYTES, 'image/svg+xml');
expect(result).toBe('application/octet-stream');
});
it('returns application/octet-stream when client claims text/javascript', async () => {
const jsBuf = Buffer.from('console.log("pwned")', 'utf8');
const result = await resolveSafeMimeType(jsBuf, 'text/javascript');
expect(result).toBe('application/octet-stream');
});
it('honours a safe client-supplied MIME when file-type cannot detect the type (e.g. CSV)', async () => {
// file-type returns octet-stream for plain CSV; client claim of text/csv is safe
const result = await resolveSafeMimeType(CSV_BYTES, 'text/csv');
expect(result).toBe('text/csv');
});
it('falls back to application/octet-stream when no client MIME is provided and file is undetectable', async () => {
const result = await resolveSafeMimeType(CSV_BYTES, undefined);
expect(result).toBe('application/octet-stream');
});
it('a PNG uploaded with a .html extension gets image/png (magic bytes win)', async () => {
// Attacker renames avatar.png to payload.html hoping to get text/html
const result = await resolveSafeMimeType(PNG_BUFFER, 'text/html');
expect(result).toBe('image/png');
});
it('an SVG uploaded with a text/plain claim gets caught by content scanner and returns application/octet-stream', async () => {
// Attacker lies and says SVG is text/plain to bypass mime check
const result = await resolveSafeMimeType(SVG_BYTES, 'text/plain');
expect(result).toBe('application/octet-stream');
});
it('an HTML fragment (<body onload) uploaded with a text/plain claim gets caught and returns application/octet-stream', async () => {
const fragment = Buffer.from('<body onload="alert(1)">', 'utf8');
const result = await resolveSafeMimeType(fragment, 'text/plain');
expect(result).toBe('application/octet-stream');
});
it('an HTML fragment (<iframe) uploaded with a text/plain claim gets caught and returns application/octet-stream', async () => {
const fragment = Buffer.from('<iframe src="javascript:alert(1)">', 'utf8');
const result = await resolveSafeMimeType(fragment, 'text/plain');
expect(result).toBe('application/octet-stream');
});
it('returns application/octet-stream when magic bytes themselves resolve to an unsafe type', async () => {
// We mocked fileTypeFromBuffer to return application/javascript for this specific buffer
const mockBuffer = Buffer.from('mock-unsafe-magic-bytes', 'utf8');
const result = await resolveSafeMimeType(mockBuffer, 'application/javascript');
expect(result).toBe('application/octet-stream');
});
});