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