feat(icons): ingest — file processor, zip unpack, URL fetch (guards)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
67
tests/icons/ingest.test.js
Normal file
67
tests/icons/ingest.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { processFile, unpackZip, fetchUrl, MAX_FILE } from '../../lib/icons/ingest.js';
|
||||
|
||||
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a, 0,0,0,0]);
|
||||
|
||||
describe('processFile', () => {
|
||||
it('slugifies name, keeps png', () => {
|
||||
const r = processFile({ name: 'My Router.png', buffer: PNG });
|
||||
expect(r.name).toBe('my-router.png');
|
||||
expect(r.buffer).toBe(PNG);
|
||||
});
|
||||
it('sanitizes svg', () => {
|
||||
const r = processFile({ name: 'x.svg', buffer: Buffer.from('<svg><script>1</script><path/></svg>') });
|
||||
expect(r.buffer.toString()).not.toMatch(/script/i);
|
||||
});
|
||||
it('rejects non-image extension', () => {
|
||||
expect(() => processFile({ name: 'x.exe', buffer: PNG })).toThrow();
|
||||
});
|
||||
it('rejects oversize', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.alloc(MAX_FILE + 1, 1) })).toThrow();
|
||||
});
|
||||
it('rejects png with bad magic', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.from('not a png') })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpackZip', () => {
|
||||
it('extracts images, skips non-image junk', () => {
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('notes.txt', Buffer.from('hi'));
|
||||
const out = unpackZip(z.toBuffer());
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
|
||||
it('skips path-traversal entries', () => {
|
||||
// adm-zip's addFile() sanitizes '../' at write time (zipnamefix), so it
|
||||
// can't produce a real traversal entry. Build one by mutating the entry
|
||||
// name at the raw level *after* addFile — this survives serialization and
|
||||
// stores '../evil.png' verbatim in the zip bytes.
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('placeholder.png', PNG);
|
||||
const entries = z.getEntries();
|
||||
entries[1].entryName = '../evil.png';
|
||||
const buf = z.toBuffer();
|
||||
// Sanity check: the traversal entry name really is in the serialized bytes.
|
||||
expect(buf.includes(Buffer.from('../evil.png'))).toBe(true);
|
||||
const out = unpackZip(buf);
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUrl', () => {
|
||||
it('rejects non-http schemes', async () => {
|
||||
await expect(fetchUrl('file:///etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
it('rejects localhost/private hosts', async () => {
|
||||
await expect(fetchUrl('http://127.0.0.1/x.png')).rejects.toThrow();
|
||||
});
|
||||
it('fetches via injected fetcher', async () => {
|
||||
const fake = async () => ({ ok: true, arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.length), headers: new Map([['content-type','image/png']]) });
|
||||
const r = await fetchUrl('https://example.com/x.png', { fetcher: fake });
|
||||
expect(Buffer.isBuffer(r.buffer)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user