Files
safetynet/apps/network-broker/proxy-forwarder.cjs
T

79 lines
9.4 KiB
JavaScript

'use strict';
const net=require('node:net'),tls=require('node:tls'),http=require('node:http');
const {parseAuthority}=require('./tls-authority.cjs');
const {validateResponse}=require('./http-response-gate.cjs');
const {GateError}=require('./http-request-gate.cjs');
const fail=code=>{throw new GateError(code);};
// Deliberately narrow signed subset: one HTTP(S) proxy for all HTTPS origins.
// PAC, system, SOCKS, fallback lists and bypass syntax are not interpreted.
function parseFixedProxy(policy){
if(!policy||policy.mode!=='fixed_servers'||typeof policy.rules!=='string'||policy.bypass!=='')fail('proxy-policy-unsupported');
const match=/^(https?):\/\/([^/?#]+)$/.exec(policy.rules);if(!match)fail('proxy-policy-unsupported');
let binding;try{binding=parseAuthority(match[2]);if(!/:[0-9]+$/.test(match[2])&&match[1]==='http')binding=parseAuthority(match[2]+':80');}catch{fail('proxy-policy-unsupported');}
return Object.freeze({protocol:match[1],...binding});
}
function createProxyForwarder({authority,current,proxyPolicy,resolveProxyRoute,resolveOriginRoute,credentials,ca,proxyCa,deadlineMs=5000,maxRequestBytes=16777216,maxResponseBytes=1048576}={}){
const proxy=parseFixedProxy(proxyPolicy);
if(typeof authority!=='string'||typeof current!=='function'||typeof resolveProxyRoute!=='function'||!Number.isInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000)fail('invalid-forward-configuration');
if(resolveOriginRoute!==undefined&&typeof resolveOriginRoute!=='function')fail('invalid-forward-configuration');
if(![maxRequestBytes,maxResponseBytes].every(n=>Number.isSafeInteger(n)&&n>=0&&n<=16777216))fail('invalid-forward-configuration');
if([ca,proxyCa].some(value=>value!==undefined&&!Buffer.isBuffer(value)&&typeof value!=='string'))fail('invalid-forward-configuration');
ca=Buffer.isBuffer(ca)?Buffer.from(ca):ca;proxyCa=Buffer.isBuffer(proxyCa)?Buffer.from(proxyCa):proxyCa;
let authorization;
if(credentials){if(Object.keys(credentials).sort().join(',')!=='password,username'||typeof credentials.username!=='string'||typeof credentials.password!=='string'||credentials.username.includes(':')||/[\r\n\0]/.test(credentials.username+credentials.password)||credentials.username.length+credentials.password.length>4096)fail('invalid-forward-configuration');authorization='Basic '+Buffer.from(credentials.username+':'+credentials.password).toString('base64');}
const active=()=>{try{return current(authority)===true;}catch{return false;}};
let pending=0;
return async function forward({request,body,signal,maxResponseBytes:limit=maxResponseBytes}){
if(pending>=8)fail('proxy-capacity-exhausted');pending++;
let raw,secure,outgoing,agent,timer,abort,heldResolution=false;const controller=new AbortController();
const privateBody=Buffer.isBuffer(body)&&body.length<=maxRequestBytes?Buffer.from(body):undefined;
try{
if(!privateBody||privateBody.length>maxRequestBytes||!Number.isSafeInteger(limit)||limit<0||limit>maxResponseBytes||!request||!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(request.method))fail('invalid-forward-request');
const url=new URL(request.url);if(url.protocol!=='https:'||url.username||url.password||url.hash||[...request.url].some(c=>c.charCodeAt(0)<=32||c.charCodeAt(0)===127||c==='\\'))fail('invalid-forward-request');
const destination=parseAuthority(url.host),headers=Object.create(null);let headerBytes=0;
for(const [name,value] of Object.entries(request.headers||{})){
if(!/^[a-z0-9-]+$/i.test(name)||typeof value!=='string'||[...value].some(c=>{const code=c.charCodeAt(0);return code!==9&&(code<32||code===127||code>255);})||headers[name.toLowerCase()]!==undefined)fail('invalid-forward-request');
if(name.toLowerCase()==='connection'&&!['close','keep-alive'].includes(value.toLowerCase()))fail('invalid-forward-request');
headerBytes+=name.length+value.length;if(headerBytes>32768)fail('invalid-forward-request');
if(!['host','content-length','connection','proxy-connection','proxy-authorization','proxy-authenticate','keep-alive','te','trailer','transfer-encoding','upgrade'].includes(name.toLowerCase()))headers[name.toLowerCase()]=value;
}
headers.host=url.host;headers.connection='close';headers['content-length']=String(privateBody.length);headers['accept-encoding']='identity';
let rejectAbort;const cancelled=new Promise((_,reject)=>{rejectAbort=reject;});cancelled.catch(()=>{});
abort=()=>{controller.abort();raw?.destroy();secure?.destroy();outgoing?.destroy();rejectAbort(new GateError('upstream-cancelled'));};
signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(abort,deadlineMs);
const guarded=promise=>Promise.race([promise,cancelled]);
if(signal?.aborted)fail('upstream-cancelled');if(!active())fail('authority-expired');
// Resolve the origin first when IP policy is active. Numeric CONNECT binds
// this exact approval to the proxy tunnel; original hostname remains TLS/SNI.
// Each outstanding original resolver retains admission after outer timeout.
const resolveBound=async(resolver,binding)=>{const work=Promise.resolve().then(()=>resolver(Object.freeze({hostname:binding.hostname,port:binding.port,signal:controller.signal})));let value;try{value=await guarded(work);}catch(error){heldResolution=true;work.finally(()=>{pending--;}).catch(()=>{});throw error;}if(controller.signal.aborted)fail('upstream-cancelled');if(!active())fail('authority-expired');return Object.freeze({approved:value?.approved,address:value?.address,port:value?.port});};
let connectAuthority=destination.authority;
if(resolveOriginRoute){const origin=await resolveBound(resolveOriginRoute,destination);if(origin.approved!==true||!net.isIP(origin.address)||origin.port!==destination.port)fail('proxy-route-unapproved');connectAuthority=parseAuthority(`${net.isIP(origin.address)===6?'['+origin.address+']':origin.address}:${origin.port}`).authority;}
const route=await resolveBound(resolveProxyRoute,proxy);
if(route.approved!==true||!net.isIP(route.address)||route.port!==proxy.port)fail('proxy-route-unapproved');if(!active())fail('authority-expired');
raw=proxy.protocol==='https'?tls.connect({host:route.address,port:route.port,servername:net.isIP(proxy.hostname)?undefined:proxy.hostname,ca:proxyCa,rejectUnauthorized:true,checkServerIdentity:(_,cert)=>tls.checkServerIdentity(proxy.hostname,cert)}):net.connect({host:route.address,port:route.port});
await guarded(new Promise((resolve,reject)=>{raw.once(proxy.protocol==='https'?'secureConnect':'connect',resolve);raw.once('error',()=>reject(new GateError('proxy-connect-failed')));}));
if(!active())fail('authority-expired');
await guarded(new Promise((resolve,reject)=>{
let bytes=Buffer.alloc(0);const data=chunk=>{if(chunk.length>32768-bytes.length){cleanup();reject(new GateError('proxy-response-invalid'));return;}bytes=Buffer.concat([bytes,chunk]);const end=bytes.indexOf('\r\n\r\n');if(end<0)return;cleanup();if(end+4!==bytes.length||!/^HTTP\/1\.[01] 200(?: [^\r\n]*)?\r\n/.test(bytes.toString('latin1'))){reject(new GateError('proxy-response-invalid'));return;}resolve();};
const error=()=>{cleanup();reject(new GateError('proxy-connect-failed'));};const cleanup=()=>{raw.off('data',data);raw.off('error',error);raw.off('end',error);};raw.on('data',data);raw.once('error',error);raw.once('end',error);
raw.write(`CONNECT ${connectAuthority} HTTP/1.1\r\nHost: ${connectAuthority}\r\n${authorization?'Proxy-Authorization: '+authorization+'\r\n':''}Connection: keep-alive\r\n\r\n`);
}));
if(!active())fail('authority-expired');
secure=tls.connect({socket:raw,servername:net.isIP(destination.hostname)?undefined:destination.hostname,ca,rejectUnauthorized:true,checkServerIdentity:(_,cert)=>tls.checkServerIdentity(destination.hostname,cert)});
await guarded(new Promise((resolve,reject)=>{secure.once('secureConnect',resolve);secure.once('error',()=>reject(new GateError('upstream-failed')));}));
if(!active())fail('authority-expired');
agent=new http.Agent({keepAlive:false});agent.createConnection=()=>secure;
const result=await guarded(new Promise((resolve,reject)=>{
outgoing=http.request({host:destination.hostname,port:destination.port,path:url.pathname+url.search,method:request.method,headers,agent,maxHeaderSize:32768},incoming=>{
const chunks=[];let bytes=0;const wipe=()=>{chunks.forEach(chunk=>chunk.fill(0));chunks.length=0;};incoming.once('close',wipe);incoming.on('data',chunk=>{bytes+=chunk.length;if(bytes>limit){incoming.destroy();reject(new GateError('upstream-response-too-large'));return;}chunks.push(Buffer.from(chunk));});
incoming.once('error',()=>reject(new GateError('upstream-failed')));incoming.once('aborted',()=>reject(new GateError('upstream-failed')));
incoming.once('end',()=>{try{if(!incoming.complete)fail('upstream-failed');resolve(validateResponse({statusCode:incoming.statusCode,headers:incoming.headers,body:Buffer.concat(chunks)},limit));}catch(error){reject(error);}finally{wipe();}});
});outgoing.once('error',()=>reject(new GateError('upstream-failed')));if(!active()){reject(new GateError('authority-expired'));return;}outgoing.end(privateBody);
}));if(!active())fail('authority-expired');return result;
}catch(error){if(error instanceof GateError)throw error;throw new GateError('upstream-failed');}
finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);outgoing?.destroy();secure?.destroy();raw?.destroy();agent?.destroy();privateBody?.fill(0);if(!heldResolution)pending--;}
};
}
module.exports={parseFixedProxy,createProxyForwarder};