200 lines
5.8 KiB
JavaScript
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 };
|