65 lines
6.4 KiB
JavaScript
65 lines
6.4 KiB
JavaScript
'use strict';
|
|
const http=require('node:http');
|
|
const {randomBytes,timingSafeEqual}=require('node:crypto');
|
|
const {bindConnectAuthority}=require('./tls-authority.cjs');
|
|
const {createInspectionServer}=require('./https-inspection-server.cjs');
|
|
const {GateError}=require('./http-request-gate.cjs');
|
|
const {createWorkAdmission}=require('./work-admission.cjs');
|
|
const {recordLifecycle}=require('./bounded-audit.cjs');
|
|
|
|
/** Isolated authenticated CONNECT-to-inspection chain. Scoped certificates
|
|
* are supplied by a trusted resolver, never generated/trusted globally here.
|
|
* No opaque tunnel or target connection is opened by this acceptor.
|
|
*/
|
|
function createConnectProxy({authority,current,resolveCertificate,inspect,forward,deadlineMs=5000,maxClients=8,maxBytes=1048576,maxResponseBytes=1048576,authMode='basic',lifecycle}) {
|
|
if(typeof authority!=='string'||!authority||typeof current!=='function'||typeof resolveCertificate!=='function'||typeof inspect!=='function'||typeof forward!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||!Number.isSafeInteger(maxClients)||maxClients<1||maxClients>64||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216||!Number.isSafeInteger(maxResponseBytes)||maxResponseBytes<0||maxResponseBytes>16777216)throw new GateError('invalid-configuration');
|
|
if(lifecycle!==undefined&&(!lifecycle||typeof lifecycle.begin!=='function'))throw new GateError('invalid-configuration');
|
|
if(!['basic','bearer'].includes(authMode))throw new GateError('invalid-configuration');
|
|
const capability=randomBytes(32).toString('base64url'),username=`safetynet-${randomBytes(16).toString('hex')}`;
|
|
const credentials=Object.freeze({username,password:capability});
|
|
const expected=Buffer.from(authMode==='basic'?`Basic ${Buffer.from(`${username}:${capability}`).toString('base64')}`:`Bearer ${capability}`);
|
|
const sockets=new Set(),legs=new Set();
|
|
const admission=createWorkAdmission({maxJobs:maxClients});
|
|
const auditAdmission=createWorkAdmission({maxJobs:maxClients}),contexts=new WeakMap();
|
|
const contextFor=socket=>{if(!lifecycle)return undefined;if(!contexts.has(socket))contexts.set(socket,lifecycle.begin());return contexts.get(socket);};
|
|
const rejected=(context,code,facts={bytes:0,detectedType:'unknown'})=>recordLifecycle({admission:auditAdmission,deadlineMs,context,code,facts,scope:'connect'});
|
|
const server=http.createServer({maxHeaderSize:8192},(req,res)=>{void rejected(contextFor(req.socket),'unsupported-method').catch(()=>{}).finally(()=>{if(!res.destroyed){res.writeHead(403,{'connection':'close'});res.end('connect-required');}});});
|
|
const reject=(socket,status,code)=>socket.end(`HTTP/1.1 ${status}\r\n${status.startsWith('407')?`Proxy-Authenticate: ${authMode==='basic'?'Basic realm="SafetyNet private broker", charset="UTF-8"':'Bearer realm="SafetyNet private broker"'}\r\n`:''}Connection: close\r\nContent-Length: ${Buffer.byteLength(code)}\r\n\r\n${code}`);
|
|
server.on('connect',async(req,socket,head)=>{
|
|
socket.pause();
|
|
let context,binding;
|
|
try {
|
|
context=contextFor(socket);
|
|
const headers=Object.create(null);
|
|
for(let index=0;index<req.rawHeaders.length;index+=2){const name=req.rawHeaders[index].toLowerCase();if(Object.hasOwn(headers,name))throw new GateError('duplicate-header');headers[name]=req.rawHeaders[index+1];}
|
|
const received=Buffer.from(headers['proxy-authorization']||'');
|
|
if(received.length!==expected.length||!timingSafeEqual(received,expected)){await rejected(context,'authentication-required');reject(socket,'407 Proxy Authentication Required','authentication-required');return;}
|
|
if(head.length||headers['transfer-encoding']!==undefined||headers['content-length']!==undefined||headers.upgrade!==undefined)throw new GateError('unsupported-connect-framing');
|
|
binding=bindConnectAuthority(req.url);
|
|
if(headers.host!==binding.authority)throw new GateError('authority-mismatch');
|
|
if(current(authority)!==true)throw new GateError('authority-expired');
|
|
let timer;
|
|
const resolverController=new AbortController(),cancelResolver=()=>resolverController.abort();
|
|
socket.once('close',cancelResolver);
|
|
const resolved=await Promise.race([admission.run(()=>resolveCertificate(binding,{signal:resolverController.signal})),new Promise((_,rejectPromise)=>{timer=setTimeout(()=>{resolverController.abort();rejectPromise(new GateError('certificate-deadline'));},deadlineMs);})]).finally(()=>{clearTimeout(timer);socket.removeListener('close',cancelResolver);resolverController.abort();});
|
|
if(socket.destroyed||current(authority)!==true)throw new GateError('authority-expired');
|
|
if(!resolved||resolved.authority!==binding.authority)throw new GateError('destination-unapproved');
|
|
const leg=createInspectionServer({key:resolved.key,cert:resolved.cert,binding,authority,current,inspect,forward,deadlineMs,maxClients,maxBytes,maxResponseBytes,workAdmission:admission,lifecycle,auditAdmission,connectionContext:context});
|
|
legs.add(leg);socket.once('close',()=>{legs.delete(leg);void leg.close().catch(()=>{});});
|
|
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
leg.acceptAuthenticatedSocket(socket);socket.resume();
|
|
} catch(error) {
|
|
try {await rejected(context,error instanceof GateError?error.code:'connect-denied',{bytes:0,detectedType:'unknown',...(binding?{host:binding.hostname}:{})});}catch{}
|
|
if(!socket.destroyed)reject(socket,'403 Forbidden','connect-denied');
|
|
}
|
|
});
|
|
server.maxConnections=maxClients;server.maxHeadersCount=32;server.headersTimeout=deadlineMs;server.requestTimeout=deadlineMs;
|
|
server.on('connection',socket=>{sockets.add(socket);socket.setTimeout(deadlineMs*3,()=>socket.destroy());socket.once('close',()=>sockets.delete(socket));});
|
|
server.on('clientError',(_error,socket)=>{void rejected(contextFor(socket),'invalid-headers').catch(()=>{}).finally(()=>socket.destroy());});
|
|
return Object.freeze({capability,authMode,credentials,
|
|
async listen(port=0){if(!Number.isInteger(port)||port<0||port>65535)throw new GateError('invalid-configuration');await new Promise((resolve,rejectPromise)=>{server.once('error',rejectPromise);server.listen(port,'127.0.0.1',()=>{server.removeListener('error',rejectPromise);resolve();});});return server.address().port;},
|
|
async close(){admission.close();auditAdmission.close();for(const socket of sockets)socket.destroy();await Promise.all([...legs].map(leg=>leg.close()));await new Promise(resolve=>server.close(resolve));}
|
|
});
|
|
}
|
|
module.exports={createConnectProxy};
|