99 lines
8.1 KiB
JavaScript
99 lines
8.1 KiB
JavaScript
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);
|
||
|
|
});
|