'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:,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};