109 lines
5.8 KiB
JavaScript
109 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};
|