102 lines
8.1 KiB
JavaScript
102 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};
|