Files
Void-Homelab/tests/icons/ingest.test.js
root 086bd1e6a3 fix: strengthen SSRF guard in fetchUrl with DNS resolution check
Add exported isBlockedAddress(ip) helper covering loopback, 0.0.0.0,
private v4 (10/8, 172.16-31, 192.168/16), link-local (169.254/16,
fe80::/10), and IPv6 ULA (fc00::/7). In fetchUrl, after the existing
literal-hostname fast-reject, resolve the hostname via dns.lookup
(all:true) when using the real fetcher and block if any resolved
address isBlockedAddress. Injected fetcher (tests) skips DNS.
Drop unused contentType from return value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:11:42 +10:00

80 lines
3.6 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import AdmZip from 'adm-zip';
import { processFile, unpackZip, fetchUrl, isBlockedAddress, 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('isBlockedAddress', () => {
it('blocks loopback IPv4', () => { expect(isBlockedAddress('127.0.0.1')).toBe(true); });
it('blocks private 10/8', () => { expect(isBlockedAddress('10.1.2.3')).toBe(true); });
it('blocks private 192.168/16', () => { expect(isBlockedAddress('192.168.0.1')).toBe(true); });
it('blocks link-local 169.254/16', () => { expect(isBlockedAddress('169.254.1.1')).toBe(true); });
it('blocks 0.0.0.0', () => { expect(isBlockedAddress('0.0.0.0')).toBe(true); });
it('blocks IPv6 loopback ::1', () => { expect(isBlockedAddress('::1')).toBe(true); });
it('blocks IPv6 ULA fc00::1', () => { expect(isBlockedAddress('fc00::1')).toBe(true); });
it('allows public 8.8.8.8', () => { expect(isBlockedAddress('8.8.8.8')).toBe(false); });
it('allows public 1.1.1.1', () => { expect(isBlockedAddress('1.1.1.1')).toBe(false); });
});
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);
});
});