Files
safetynet/apps/network-broker/http-request-gate.cjs
T

110 lines
5.8 KiB
JavaScript

'use strict';
const {createHash}=require('node:crypto');
const {performance}=require('node:perf_hooks');
const digest=body=>createHash('sha256').update(body).digest('hex');
class GateError extends Error {
constructor(code) { super(code); this.name='GateError'; this.code=code; }
}
const fail=code=>{throw new GateError(code);};
function parseRequest(request) {
const headers=Object.create(null);
if(!Array.isArray(request.rawHeaders)||request.rawHeaders.length%2) fail('invalid-headers');
for(let index=0;index<request.rawHeaders.length;index+=2) {
const name=request.rawHeaders[index].toLowerCase(),value=request.rawHeaders[index+1];
if(headers[name]!==undefined) fail('duplicate-header');
headers[name]=value;
}
if(headers.upgrade!==undefined||headers['transfer-encoding']!==undefined||headers['content-encoding']!==undefined||headers.expect!==undefined) fail('unsupported-framing');
if(headers.connection&&headers.connection.toLowerCase()!=='close'&&headers.connection.toLowerCase()!=='keep-alive') fail('unsupported-framing');
if(!/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(request.method)) fail('unsupported-method');
let destination;
try {destination=new URL(request.url);} catch {fail('invalid-destination');}
// First gate supports explicit HTTP proxy requests only. TLS CONNECT,
// streaming protocols and OS egress confinement need separate enforcement.
if(destination.protocol!=='http:'||destination.username||destination.password||destination.hash) fail('invalid-destination');
if(!headers.host||headers.host.toLowerCase()!==destination.host.toLowerCase()) fail('authority-mismatch');
const length=headers['content-length'];
if(length!==undefined&&!/^(0|[1-9][0-9]*)$/.test(length)) fail('invalid-length');
const expected=length===undefined?0:Number(length);
if(!Number.isSafeInteger(expected)) fail('invalid-length');
return Object.freeze({method:request.method,url:destination.href,headers:Object.freeze(headers),expected});
}
/** Provisional trusted broker API, not a public renderer/socket API.
* current(authority) MUST synchronously verify enrollment, policy generation,
* expiration and revocation. forward is trusted and must make its first dial
* synchronously when called, without yielding before using the supplied bytes.
* This module never forwards an IncomingMessage or an inspector-owned buffer.
*/
async function gateRequest(request,{inspect,current,forward,authority,maxBytes=1048576,deadlineMs=5000,signal}) {
if(typeof authority!=='string'||!authority||typeof inspect!=='function'||typeof current!=='function'||typeof forward!=='function'||!Number.isSafeInteger(maxBytes)||maxBytes<0||!Number.isSafeInteger(deadlineMs)||deadlineMs<1) fail('invalid-configuration');
const snapshot=parseRequest(request);
if(snapshot.expected>maxBytes) fail('body-too-large');
const isCurrent=()=>{try {return current(authority)===true;} catch {return false;}};
if(!isCurrent()) fail('authority-expired');
const expires=performance.now()+deadlineMs;
let timer,abortListener,requestAbort,requestError,requestClose,data,end;
let settled=false,body;
const chunks=[];
let bytes=0;
const stop=new AbortController();
try {
const buffered=await new Promise((resolve,reject)=>{
const deny=code=>{if(settled)return;settled=true;stop.abort();request.pause();reject(new GateError(code));};
timer=setTimeout(()=>deny('deadline'),deadlineMs);
abortListener=()=>deny('aborted');
requestAbort=()=>deny('aborted');
requestError=()=>deny('request-error');
requestClose=()=>{if(!request.complete)deny('incomplete-body');};
data=chunk=>{
bytes+=chunk.length;
if(bytes>maxBytes||bytes>snapshot.expected){deny('body-too-large');return;}
chunks.push(Buffer.from(chunk));
};
end=()=>{
if(settled)return;
if(!request.complete||bytes!==snapshot.expected){deny('incomplete-body');return;}
resolve(Buffer.concat(chunks));
};
signal?.addEventListener('abort',abortListener,{once:true});
request.on('aborted',requestAbort).on('error',requestError).on('close',requestClose).on('data',data).once('end',end);
if(signal?.aborted||request.aborted)deny('aborted');
else if(request.readableEnded)end();
});
body=buffered;
if(performance.now()>=expires)fail('deadline');
if(!isCurrent())fail('authority-expired');
const originalHash=digest(body);
// Deadline and abort remain active during asynchronous inspection.
const verdict=await Promise.race([
Promise.resolve().then(()=>inspect(Object.freeze({request:snapshot,body:Buffer.from(body),signal:stop.signal}))),
new Promise((_,reject)=>{
if(stop.signal.aborted)reject(new GateError(signal?.aborted?'aborted':'deadline'));
else stop.signal.addEventListener('abort',()=>reject(new GateError(signal?.aborted?'aborted':'deadline')),{once:true});
})
]);
if(stop.signal.aborted)fail('aborted');
if(performance.now()>=expires)fail('deadline');
if(verdict?.decision!=='allow')fail('inspection-denied');
if(!isCurrent())fail('authority-expired');
if(digest(body)!==originalHash)fail('integrity-failed');
settled=true;
clearTimeout(timer);
// Final verification and call have no asynchronous gap.
return await forward(Object.freeze({request:snapshot,body:Buffer.from(body)}));
} catch(error) {
if(error instanceof GateError)throw error;
throw new GateError('inspection-or-forward-failed');
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort',abortListener);
request.removeListener('aborted',requestAbort).removeListener('error',requestError).removeListener('close',requestClose).removeListener('data',data).removeListener('end',end);
for(const chunk of chunks)chunk.fill(0);
body?.fill(0);
}
}
module.exports={gateRequest,GateError};