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

103 lines
8.1 KiB
JavaScript

'use strict';
const https=require('node:https');
const tls=require('node:tls');
const {isIP}=require('node:net');
const {parseAuthority}=require('./tls-authority.cjs');
const {validateResponse}=require('./http-response-gate.cjs');
const {GateError}=require('./http-request-gate.cjs');
const {createWorkAdmission}=require('./work-admission.cjs');
const methods=new Set(['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS']);
const hop=new Set(['host','content-length','connection','proxy-connection','proxy-authorization','proxy-authenticate','keep-alive','te','trailer','transfer-encoding','upgrade']);
const fail=code=>{throw new GateError(code);};
function requestSnapshot(request,body,maxBytes){
if(!request||!methods.has(request.method)||!Buffer.isBuffer(body)||body.length>maxBytes||typeof request.url!=='string'||!request.headers||typeof request.headers!=='object')fail('invalid-forward-request');
let url,binding;try{
const match=/^https:\/\/([^/?#]+)(?:[/?]|$)/.exec(request.url);if(!match)fail('invalid-forward-request');
binding=parseAuthority(match[1]);url=new URL(request.url);
if(url.username||url.password||url.hash||[...request.url].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)===127||character==='\\')||url.protocol!=='https:')fail('invalid-forward-request');
}catch{fail('invalid-forward-request');}
const headers=Object.create(null),seen=new Set();let headerBytes=0;
const connectionNamed=new Set();
for(const [name,value] of Object.entries(request.headers))if(name.toLowerCase()==='connection'){
if(typeof value!=='string')fail('invalid-forward-request');
for(const token of value.split(',')){if(!/^[a-z0-9-]+$/i.test(token.trim()))fail('invalid-forward-request');connectionNamed.add(token.trim().toLowerCase());}
}
for(const [name,value] of Object.entries(request.headers)){
const lower=name.toLowerCase();
if(!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name)||seen.has(lower)||typeof value!=='string'||[...value].some(character=>{const code=character.charCodeAt(0);return code!==9&&(code<32||code===127||code>255);}))fail('invalid-forward-request');
seen.add(lower);headerBytes+=name.length+value.length;if(headerBytes>32768)fail('invalid-forward-request');
if(!hop.has(lower)&&!connectionNamed.has(lower))headers[lower]=value;
}
headers['accept-encoding']='identity';headers.host=url.host;headers['content-length']=String(body.length);headers.connection='close';
return {binding,path:url.pathname+url.search,method:request.method,headers};
}
/** Trusted route resolver MUST approve the original hostname/port and return
* {approved:true,address:<numeric IP>,port:<same port>}. It must apply corporate
* DNS/proxy policy; this transport never does system lookup or follows redirects.
* ca is an optional trusted configuration/test CA, never renderer-supplied.
*/
function createHttpsForwarder({authority,current,resolveRoute,ca,deadlineMs=5000,maxRequestBytes=16777216,maxResponseBytes=1048576,maxPendingResolutions=8}={}){
if(typeof authority!=='string'||!authority||typeof current!=='function'||typeof resolveRoute!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||![maxRequestBytes,maxResponseBytes].every(n=>Number.isSafeInteger(n)&&n>=0&&n<=16777216))fail('invalid-forward-configuration');
if(ca!==undefined&&!Buffer.isBuffer(ca)&&typeof ca!=='string')fail('invalid-forward-configuration');
if(!Number.isSafeInteger(maxPendingResolutions)||maxPendingResolutions<1||maxPendingResolutions>64)fail('invalid-forward-configuration');
// Separate budget from outer forward admission: timed-out resolvers retain
// their slot until actual settlement, avoiding nested single-slot deadlock.
const resolutions=createWorkAdmission({maxJobs:maxPendingResolutions});
const trustedCa=Buffer.isBuffer(ca)?Buffer.from(ca):ca;
const isCurrent=()=>{try{return current(authority)===true;}catch{return false;}};
return async function forward({request,body,signal,maxResponseBytes:requestedLimit=maxResponseBytes}){
if(!Number.isSafeInteger(requestedLimit)||requestedLimit<0||requestedLimit>maxResponseBytes)fail('invalid-forward-request');
const snapshot=requestSnapshot(request,body,maxRequestBytes),privateBody=Buffer.from(body),controller=new AbortController();
let outgoing,timer,externalAbort,cancel;
const cancellation=new Promise((_,reject)=>{cancel=()=>{outgoing?.destroy();reject(new GateError('upstream-cancelled'));};controller.signal.addEventListener('abort',cancel,{once:true});});
void cancellation.catch(()=>{});
try{
externalAbort=()=>controller.abort();signal?.addEventListener('abort',externalAbort,{once:true});
timer=setTimeout(()=>controller.abort(),deadlineMs);
if(signal?.aborted)controller.abort();
if(controller.signal.aborted)fail('upstream-cancelled');
if(!isCurrent())fail('authority-expired');
let resolution;
try{resolution=resolutions.run(()=>resolveRoute(Object.freeze({hostname:snapshot.binding.hostname,port:snapshot.binding.port,signal:controller.signal})));}
catch(error){if(error.code==='work_capacity_exhausted')fail('route-capacity-exhausted');throw error;}
const route=await Promise.race([resolution,cancellation]);
if(controller.signal.aborted)fail('upstream-cancelled');
if(!isCurrent())fail('authority-expired');
if(!route||route.approved!==true||!isIP(route.address)||route.port!==snapshot.binding.port)fail('route-unapproved');
const response=await Promise.race([new Promise((resolve,reject)=>{
// Last synchronous authorization check precedes request creation/dial.
if(!isCurrent()){reject(new GateError('authority-expired'));return;}
outgoing=https.request({host:route.address,port:route.port,path:snapshot.path,method:snapshot.method,headers:snapshot.headers,
servername:isIP(snapshot.binding.hostname)?undefined:snapshot.binding.hostname,
ca:trustedCa,rejectUnauthorized:true,checkServerIdentity:(_hostname,certificate)=>tls.checkServerIdentity(snapshot.binding.hostname,certificate),
lookup:(_hostname,_options,callback)=>callback(new Error('unexpected-name-resolution')),agent:false,maxHeaderSize:32768,signal:controller.signal},incoming=>{
const chunks=[];let bytes=0,headerBytes=0;
for(const value of incoming.rawHeaders)headerBytes+=value.length;
const wipe=()=>{for(const chunk of chunks)chunk.fill(0);chunks.length=0;};
incoming.once('close',wipe);
if(headerBytes>32768){reject(new GateError('invalid-upstream-response'));incoming.destroy();return;}
incoming.on('data',chunk=>{bytes+=chunk.length;if(bytes>requestedLimit){reject(new GateError('upstream-response-too-large'));incoming.destroy();return;}chunks.push(Buffer.from(chunk));});
incoming.once('aborted',()=>reject(new GateError('upstream-failed')));
incoming.once('error',()=>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)},requestedLimit));}
catch(error){reject(error);}finally{wipe();}
});
});
outgoing.once('error',()=>reject(new GateError('upstream-failed')));
// TLS setup is asynchronous. Reauthorize at the actual secure socket
// before any HTTP headers/body are released, not merely before dial.
outgoing.once('socket',socket=>socket.once('secureConnect',()=>{
if(controller.signal.aborted){reject(new GateError('upstream-cancelled'));outgoing.destroy();return;}
if(!isCurrent()){reject(new GateError('authority-expired'));outgoing.destroy();return;}
outgoing.end(privateBody);
}));
}),cancellation]);
if(!isCurrent())fail('authority-expired');
return response;
}catch(error){outgoing?.destroy();if(error instanceof GateError)throw error;throw new GateError('upstream-failed');}
finally{clearTimeout(timer);signal?.removeEventListener('abort',externalAbort);controller.signal.removeEventListener('abort',cancel);privateBody.fill(0);}
};
}
module.exports={createHttpsForwarder};