/** * Net capability pack — CORS-free HTTPS/HTTP via bare-fetch. * Private / loopback / link-local / metadata destinations are blocked. */ const b4a = require('b4a'); const MAX_BODY_BYTES = 2 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 30000; const MAX_TIMEOUT_MS = 60000; const MAX_CHUNK_CHARS = 700000; function isBlockedHostname(hostname) { if (!hostname) return true; const h = String(hostname).toLowerCase().replace(/^\[|\]$/g, ''); if ( h === 'localhost' || h.endsWith('.localhost') || h === '0.0.0.0' || h === '::' || h === '::1' || h === 'metadata.google.internal' || h.endsWith('.internal') ) { return true; } const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const a = Number(v4[1]); const b = Number(v4[2]); if (a === 0 || a === 10 || a === 127) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; } if (h.includes(':')) { if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true; } return false; } function assertPublicHttpUrl(raw) { let url; try { url = new URL(String(raw)); } catch (_) { throw new Error('invalid URL'); } if (url.protocol !== 'https:' && url.protocol !== 'http:') { throw new Error('only http(s) URLs are allowed'); } if (isBlockedHostname(url.hostname)) { throw new Error('private, loopback, and metadata hosts are blocked'); } return url; } function headersToObject(headers) { const out = {}; if (!headers) return out; if (typeof headers.forEach === 'function') { headers.forEach((value, name) => { out[name] = value; }); return out; } if (typeof headers === 'object') { for (const [k, v] of Object.entries(headers)) out[k] = String(v); } return out; } function emitBase64Chunks(emit, jobId, buffer) { const b64 = b4a.toString(buffer, 'base64'); let index = 0; for (let offset = 0; offset < b64.length; offset += MAX_CHUNK_CHARS) { emit('cap-chunk', { pack: 'net', jobId, index, data: b64.slice(offset, offset + MAX_CHUNK_CHARS), }); index++; } return index; } function createNetPack() { let fetchImpl = null; function loadFetch() { if (fetchImpl) return fetchImpl; const mod = require('bare-fetch'); fetchImpl = typeof mod === 'function' ? mod : mod.default || mod.fetch; return fetchImpl; } const commands = { async fetch(ctx) { const { payload, reply, emit } = ctx; const parsed = assertPublicHttpUrl(payload.url); const method = String(payload.method || 'GET').toUpperCase(); if (!/^[A-Z]+$/.test(method) || ['CONNECT', 'TRACE', 'TRACK'].includes(method)) { reply({ ok: false, error: 'method not allowed' }); return; } const timeoutMs = Math.min( Math.max(1, payload.timeoutMs || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS ); const fetch = loadFetch(); const init = { method, headers: payload.headers || {}, }; if (payload.bodyBase64) init.body = b4a.from(payload.bodyBase64, 'base64'); else if (payload.body != null) init.body = typeof payload.body === 'string' ? payload.body : JSON.stringify(payload.body); let controller = null; if (typeof AbortController === 'function') { controller = new AbortController(); init.signal = controller.signal; } const timer = setTimeout(() => { try { if (controller) controller.abort(); } catch (_) {} }, timeoutMs); try { const res = await fetch(parsed.href, init); let finalUrl = parsed; try { if (res.url) finalUrl = assertPublicHttpUrl(res.url); } catch (err) { reply({ ok: false, error: 'redirect blocked: ' + err.message }); return; } const buf = await res.buffer(); if (buf.length > MAX_BODY_BYTES) { reply({ ok: false, error: `response too large (${buf.length} > ${MAX_BODY_BYTES})`, status: res.status, url: finalUrl.href, }); return; } const as = payload.as || 'text'; const meta = { ok: true, status: res.status, statusText: res.statusText || '', url: finalUrl.href, redirected: !!res.redirected, headers: headersToObject(res.headers), size: buf.length, }; if (as === 'json') { try { meta.json = JSON.parse(b4a.toString(buf, 'utf8')); } catch (err) { reply({ ok: false, error: 'invalid JSON: ' + err.message, status: res.status }); return; } reply(meta); return; } if (as === 'base64') { if (buf.length > 400 * 1024) { const jobId = payload.jobId || `net_${Date.now()}`; reply({ ...meta, jobId, streaming: true }); const chunks = emitBase64Chunks(emit, jobId, buf); emit('cap-end', { pack: 'net', jobId, chunks, size: buf.length, url: finalUrl.href }); return; } meta.dataBase64 = b4a.toString(buf, 'base64'); reply(meta); return; } meta.text = b4a.toString(buf, 'utf8'); reply(meta); } catch (err) { const msg = err && err.name === 'AbortError' ? 'request timed out' : err.message; reply({ ok: false, error: msg || 'fetch failed' }); } finally { clearTimeout(timer); } }, }; return { id: 'net', commands, }; } module.exports = { createNetPack, isBlockedHostname, assertPublicHttpUrl };