Files
safetynet/tests/broker-https-inspection.test.mjs
T

99 lines
8.1 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import https from 'node:https';
import fs from 'node:fs';
import {X509Certificate} from 'node:crypto';
import {once} from 'node:events';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url);
const {bindConnectAuthority}=require('../apps/network-broker/tls-authority.cjs');
const {createInspectionServer}=require('../apps/network-broker/https-inspection-server.cjs');
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url));
const key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
async function fixture(t,inspect,current=()=>true,customForward,config={}) {
const received=[];let connections=0;
const receiver=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain','set-cookie':['session=synthetic; Secure; HttpOnly','locale=en; Secure']});res.end('accepted');});
receiver.on('connection',()=>connections++);
receiver.listen(0,'127.0.0.1');await once(receiver,'listening');
const destination=`localhost:${receiver.address().port}`;
const broker=createInspectionServer({key,cert,binding:bindConnectAuthority(destination),authority:'signed-policy-1',current,inspect,deadlineMs:1000,...config,forward:customForward||(({request,body,signal})=>new Promise((resolve,reject)=>{
assert.equal(new URL(request.url).protocol,'https:');
const outgoing=https.request({host:'127.0.0.1',port:receiver.address().port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:destination,'content-length':body.length}},res=>{const chunks=[];res.on('data',chunk=>chunks.push(chunk));res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});outgoing.on('error',reject);outgoing.end(body);
}))});
const port=await broker.listen();
t.after(async()=>{await broker.close();receiver.closeAllConnections();await new Promise(r=>receiver.close(r));});
const send=(body,{host=destination,servername='localhost',signal}={})=>new Promise((resolve,reject)=>{
const req=https.request({host:'127.0.0.1',port,method:'POST',path:'/submit',servername,ca:cert,rejectUnauthorized:true,agent:false,signal,headers:{host,'content-length':Buffer.byteLength(body)}},res=>{let result='';res.on('data',c=>result+=c);res.on('end',()=>resolve({status:res.statusCode,result}));});req.setTimeout(2000,()=>req.destroy(new Error('test-deadline')));req.on('error',reject);req.end(body);
});
return {send,received,destination,get connections(){return connections;}};
}
test('synthetic TLS certificate explicitly covers localhost and loopback',()=>{
const certificate=new X509Certificate(cert);
assert.equal(certificate.checkHost('localhost'),'localhost');assert.equal(certificate.checkIP('127.0.0.1'),'127.0.0.1');
});
test('verified TLS delivers benign original and blocks secret before upstream TCP dial',async t=>{
const f=await fixture(t,async({body,request})=>{assert.equal(new URL(request.url).protocol,'https:');const decision=body.toString().includes('SECRET')?'block':'allow';body.fill(120);return {decision};});
assert.equal((await f.send('SECRET')).status,403);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
assert.deepEqual(await f.send('public'),{status:201,result:'accepted'});assert.equal(f.connections,1);assert.deepEqual(f.received,['public']);
});
test('HTTPS inspection delay releases no TCP connection until complete approval',async t=>{
let entered,release;const waiting=new Promise(r=>entered=r),hold=new Promise(r=>release=r);
const f=await fixture(t,async()=>{entered();await hold;return {decision:'allow'};});
const pending=f.send('public');await waiting;assert.equal(f.connections,0);release();assert.equal((await pending).status,201);assert.equal(f.connections,1);
});
test('revocation during HTTPS inspection blocks without dialing',async t=>{
let valid=true;
const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},()=>valid);
assert.deepEqual(await f.send('public'),{status:403,result:'authority-expired'});assert.equal(f.connections,0);
});
test('inner Host mismatch and missing DNS SNI reject before inspection or dial',async t=>{
let inspections=0;
const f=await fixture(t,async()=>{inspections++;return {decision:'allow'};});
assert.deepEqual(await f.send('public',{host:'evil.example:443'}),{status:403,result:'authority-mismatch'});
assert.deepEqual(await f.send('public',{servername:''}),{status:403,result:'missing-sni'});
assert.equal(inspections,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
});
test('upstream failure returns fixed 502 without retry or private errors',async t=>{
let attempts=0;
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,async()=>{attempts++;throw new Error('private upstream credentials');});
assert.deepEqual(await f.send('public'),{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(f.connections,0);
});
test('client disconnect aborts in-flight forward once without retry',async t=>{
let entered,aborted,attempts=0;
const started=new Promise(r=>entered=r),cancelled=new Promise(r=>aborted=r);
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{attempts++;signal.addEventListener('abort',()=>{aborted();reject(new Error('cancelled'));},{once:true});entered();}));
const controller=new AbortController();
const pending=f.send('public',{signal:controller.signal});const rejection=assert.rejects(pending);
await started;controller.abort();await rejection;
await Promise.race([cancelled,new Promise((_,reject)=>{const timer=setTimeout(()=>reject(new Error('abort-deadline')),1000);timer.unref();})]);
assert.equal(attempts,1);
});
test('forwarding wall clock deadline cancels a continuously active upstream once',async t=>{
let attempts=0,aborts=0;
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{
attempts++;
// No completion, just work continuing independently of socket idle.
const activity=setInterval(()=>{},2);
signal.addEventListener('abort',()=>{aborts++;clearInterval(activity);reject(new Error('private deadline detail'));},{once:true});
}),{deadlineMs:100});
const result=await f.send('public');
assert.deepEqual(result,{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(aborts,1);
});
test('response bounds reject invalid construction immediately',()=>{
for(const maxResponseBytes of [-1,1.5,16777217])assert.throws(()=>createInspectionServer({key,cert,authority:'p',current:()=>true,inspect:async()=>({decision:'allow'}),forward:async()=>{},maxResponseBytes}),{code:'invalid-configuration'});
});
test('standalone HTTPS job budget survives timed-out requests on new sockets',async t=>{
let calls=0;
const f=await fixture(t,()=>{calls++;return new Promise(()=>{});},()=>true,undefined,{maxClients:1,deadlineMs:100});
for(let index=0;index<4;index++)assert.equal((await f.send('public')).status,403);
assert.equal(calls,1);assert.equal(f.connections,0);
});
test('one absolute deadline spans inspection and forwarding without resetting the budget',async t=>{
let inspectEntered,releaseInspect,forwardEntered,releaseForward;const inspected=new Promise(resolve=>inspectEntered=resolve),first=new Promise(resolve=>releaseInspect=resolve),forwarded=new Promise(resolve=>forwardEntered=resolve),second=new Promise(resolve=>releaseForward=resolve);let signal;
const f=await fixture(t,async()=>{inspectEntered();await first;return {decision:'allow'};},()=>true,async value=>{signal=value.signal;forwardEntered();await second;return {statusCode:201,headers:{},body:Buffer.from('must not release')};},{deadlineMs:100});
const pending=f.send('public');await inspected;await new Promise(resolve=>setTimeout(resolve,55));releaseInspect();await forwarded;
const result=await pending;assert.equal(result.status,502);assert.equal(result.result,'upstream-failed');assert.equal(signal.aborted,true);releaseForward();assert.equal(f.connections,0);
});