Files
Void-Homelab/lib/icons/ingest.js
root 26a9be51d0 fix: drop jpg/jpeg support from icon system (svg + png only)
Icons.display path only handles svg/png, so jpg-backed icons never
rendered. Remove jpg/jpeg: drop from EXT map and magicOk in ingest.js,
narrow FILE regex in sets.js to (svg|png), update the file input
accept attribute in icon_sets_panel.js, and simplify the content-type
ternary in the icon_sets route (jpeg branch was now unreachable).

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

130 lines
5.1 KiB
JavaScript
Raw 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.
// lib/icons/ingest.js
import path from 'node:path';
import dns from 'node:dns';
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' };
const PNG_SIG = [0x89,0x50,0x4e,0x47];
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 === '.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;
/**
* Returns true if the given IP string is a blocked (loopback, private,
* link-local, or ULA) address that should not be fetched.
* Handles both IPv4 and IPv6.
*/
export function isBlockedAddress(ip) {
if (!ip) return true;
// IPv6 loopback
if (ip === '::1') return true;
// IPv4-mapped loopback ::ffff:127.x.x.x
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
const v4 = v4mapped ? v4mapped[1] : ip;
if (/^\d+\.\d+\.\d+\.\d+$/.test(v4)) {
const parts = v4.split('.').map(Number);
const [a, b] = parts;
// 0.0.0.0
if (a === 0) return true;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 — link-local
if (a === 169 && b === 254) return true;
return false;
}
// IPv6 checks (expand to lower-case for prefix matching)
const lower = ip.toLowerCase();
// ::1 is caught above; handle full-form loopback
if (lower === '0:0:0:0:0:0:0:1') return true;
// fe80::/10 — link-local (fe80 febf)
if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true;
// fc00::/7 — ULA (fc00 fdff)
if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true;
return false;
}
function dnsLookupAll(hostname) {
return new Promise((resolve, reject) =>
dns.lookup(hostname, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs))
);
}
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
// DNS-resolved address check, size + timeout caps. `fetcher` injectable for tests.
export async function fetchUrl(url, { fetcher } = {}) {
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');
// Fast literal-hostname guard (catches raw IP strings and 'localhost' without DNS)
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
// DNS resolution guard — only when using the real fetcher (not in tests)
if (!fetcher) {
const addrs = await dnsLookupAll(u.hostname).catch(() => []);
if (addrs.some(a => isBlockedAddress(a.address))) throw new Error('blocked_host');
}
const realFetcher = fetcher ?? fetch;
const res = await realFetcher(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');
return { buffer: Buffer.from(ab) };
}
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }