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:
root
2026-06-09 08:35:00 +10:00
parent bfecb757b4
commit 207ea906ee
4 changed files with 155 additions and 2 deletions

75
lib/icons/ingest.js Normal file
View File

@@ -0,0 +1,75 @@
// lib/icons/ingest.js
import path from 'node:path';
import AdmZip from 'adm-zip';
import { sanitizeSvg } from './sanitize.js';
export const MAX_FILE = 256 * 1024; // 256 KB per icon
export const MAX_ZIP_ENTRIES = 200;
export const MAX_ZIP_TOTAL = 5 * 1024 * 1024; // 5 MB uncompressed
export const MAX_URL_BYTES = 5 * 1024 * 1024;
const EXT = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' };
const PNG_SIG = [0x89,0x50,0x4e,0x47];
const JPG_SIG = [0xff,0xd8,0xff];
function slugBase(name) {
return path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function magicOk(ext, buf) {
if (ext === '.png') return PNG_SIG.every((b, i) => buf[i] === b);
if (ext === '.jpg' || ext === '.jpeg') return JPG_SIG.every((b, i) => buf[i] === b);
if (ext === '.svg') return buf.toString('utf8', 0, 400).includes('<svg');
return false;
}
// Validate + normalize one icon. Returns { name, buffer, ext, contentType }. Throws on invalid.
export function processFile({ name, buffer }) {
const ext = path.extname(name).toLowerCase();
if (!EXT[ext]) throw new Error('unsupported_type');
if (!buffer || buffer.length === 0) throw new Error('empty');
if (buffer.length > MAX_FILE) throw new Error('too_large');
if (!magicOk(ext, buffer)) throw new Error('bad_magic');
const base = slugBase(name);
if (!base) throw new Error('bad_name');
const out = ext === '.svg' ? Buffer.from(sanitizeSvg(buffer)) : buffer;
return { name: `${base}${ext}`, buffer: out, ext, contentType: EXT[ext] };
}
// Extract image entries from a zip buffer; flatten basenames, skip traversal/junk.
export function unpackZip(buffer) {
const zip = new AdmZip(buffer);
const entries = zip.getEntries();
if (entries.length > MAX_ZIP_ENTRIES) throw new Error('too_many_entries');
const out = []; let total = 0;
for (const e of entries) {
if (e.isDirectory) continue;
const ext = path.extname(e.entryName).toLowerCase();
if (!EXT[ext]) continue; // skip non-images
if (/(^|[\\/])\.\.([\\/]|$)/.test(e.entryName)) continue; // skip traversal
const data = e.getData();
total += data.length;
if (total > MAX_ZIP_TOTAL) throw new Error('zip_too_big');
try { out.push(processFile({ name: path.basename(e.entryName), buffer: data })); }
catch { /* skip individually-invalid entries */ }
}
return out;
}
const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?)/i;
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
// size + timeout caps. `fetcher` injectable for tests.
export async function fetchUrl(url, { fetcher = fetch } = {}) {
let u;
try { u = new URL(url); } catch { throw new Error('bad_url'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad_scheme');
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
const res = await fetcher(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
if (!res.ok) throw new Error('fetch_failed');
const ab = await res.arrayBuffer();
if (ab.byteLength > MAX_URL_BYTES) throw new Error('too_large');
const ct = (res.headers.get ? res.headers.get('content-type') : res.headers.get?.('content-type')) || '';
return { buffer: Buffer.from(ab), contentType: ct };
}
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }