Files
Void-Homelab/lib/icons/ingest.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

132 lines
5.3 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', '.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;
/**
* 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; }