Files
BridgeSwarm/native-host/capabilities/net.js
T
Raven Scott 1bc6f445ac
CI / Build & Test (push) Successful in 10m34s
Updates
2026-08-12 17:01:57 -04:00

200 lines
5.8 KiB
JavaScript

/**
* 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 };