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>
130 lines
5.1 KiB
JavaScript
130 lines
5.1 KiB
JavaScript
// 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; }
|