Files
safetynet/apps/network-broker/connect-inspection-proxy.cjs
T

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