import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; import {once} from 'node:events'; import {PassThrough} from 'node:stream'; import {createRequire} from 'node:module'; const {gateRequest}=createRequire(import.meta.url)('../apps/network-broker/http-request-gate.cjs'); async function fixture(t,inspect,options={}) { const received=[]; let connections=0; const receiver=http.createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('ok');}); receiver.on('connection',()=>connections++); receiver.listen(0,'127.0.0.1');await once(receiver,'listening'); const target=`http://127.0.0.1:${receiver.address().port}/submit`; const decisions=[]; const broker=http.createServer((req,res)=>{ gateRequest(req,{authority:'policy-1',current:()=>true,inspect,...options,forward:({request,body})=>new Promise((resolve,reject)=>{ const outgoing=http.request(request.url,{method:request.method,headers:{'content-length':body.length}},response=>{response.resume();response.on('end',resolve);}); outgoing.on('error',reject);outgoing.end(body); })}).then(()=>{decisions.push('allow');res.end('ok');},error=>{decisions.push(error.code);res.statusCode=403;res.end(error.code);}); }); broker.listen(0,'127.0.0.1');await once(broker,'listening'); t.after(async()=>{broker.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(r=>broker.close(r)),new Promise(r=>receiver.close(r))]);}); const send=body=>new Promise((resolve,reject)=>{const req=http.request({host:'127.0.0.1',port:broker.address().port,path:target,method:'POST',headers:{host:new URL(target).host,'content-length':Buffer.byteLength(body)}},res=>{res.resume();res.on('end',()=>resolve(res.statusCode));});req.on('error',reject);req.end(body);}); return {received,decisions,send,target,broker,get connections(){return connections;}}; } async function rawRequest(t,broker,payload) { const socket=net.connect(broker.address().port,'127.0.0.1'); t.after(()=>socket.destroy()); return await new Promise((resolve,reject)=>{ let text='',finished=false; const timer=setTimeout(()=>finish(new Error('raw-request-deadline')),2000); const finish=error=>{if(finished)return;finished=true;clearTimeout(timer);socket.destroy();if(error)reject(error);else resolve(text);}; socket.on('connect',()=>socket.end(payload)); socket.on('data',chunk=>text+=chunk); socket.on('error',finish); socket.on('end',()=>finish()); socket.on('close',()=>finish()); }); } test('controlled HTTP receiver sees benign original only; inspector mutation cannot alter release',async t=>{ const f=await fixture(t,async({body})=>{const secret=body.toString().includes('SECRET');body.fill(120);return {decision:secret?'block':'allow'};}); assert.equal(await f.send('public text'),200);assert.deepEqual(f.received,['public text']); assert.equal(await f.send('SECRET'),403);assert.deepEqual(f.received,['public text']); }); test('no upstream bytes or dial before delayed allow',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 request=f.send('public');await waiting;assert.deepEqual(f.received,[]);assert.equal(f.connections,0);release();assert.equal(await request,200);assert.equal(f.connections,1); }); test('policy revoked while inspection waits prevents release',async t=>{ let valid=true; const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},{current:()=>valid}); assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['authority-expired']); }); test('bounded body and inspection deadline fail closed',async t=>{ const f=await fixture(t,()=>new Promise(()=>{}),{maxBytes:8,deadlineMs:30}); assert.equal(await f.send('too long for this bound'),403);assert.equal(await f.send('short'),403); assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['body-too-large','deadline']); }); test('abort during inspection prevents release',async t=>{ const controller=new AbortController(); const f=await fixture(t,async()=>{controller.abort();return {decision:'allow'};},{signal:controller.signal}); assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['aborted']); }); test('actual duplicate Host is denied, no upstream request',async t=>{ const f=await fixture(t,async()=>({decision:'allow'})); const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\nHost: attacker.invalid\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); assert.match(result,/403/);assert.deepEqual(f.received,[]);assert.equal(f.connections,0);assert.deepEqual(f.decisions,['duplicate-header']); }); test('physical chunked and CL+TE requests never inspect or dial',async t=>{ let inspected=0; const f=await fixture(t,async()=>{inspected++;return {decision:'allow'};}); for(const length of ['', 'Content-Length: 6\r\n']) { const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\n${length}Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n6\r\nSECRET\r\n0\r\n\r\n`); assert.match(result,/HTTP\/1\.1 (400|403)/); } assert.equal(inspected,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]); }); test('ambiguous framing and unsupported transports rejected before inspection',async()=>{ for(const [headers,method,url] of [ [['Host','example.test','Content-Length','1','Content-Length','1'],'POST','http://example.test/'], [['Host','example.test','Content-Length','1','Transfer-Encoding','chunked'],'POST','http://example.test/'], [['Host','example.test','Upgrade','websocket'],'GET','http://example.test/'], [['Host','example.test'],'CONNECT','example.test:443'], [['Host','example.test','Content-Encoding','gzip'],'POST','http://example.test/'], [['Host','evil.test'],'GET','http://example.test/'] ]) { const req=new PassThrough();Object.assign(req,{rawHeaders:headers,method,url,complete:true}); await assert.rejects(gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')})); } }); test('truncated body and stream error return fixed errors without release',async()=>{ for(const kind of ['end','error','aborted']) { const req=new PassThrough();Object.assign(req,{rawHeaders:['Host','example.test','Content-Length','3'],method:'POST',url:'http://example.test/',complete:false}); const promise=gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')}); if(kind==='end')req.end('x');else req.emit(kind, new Error('confidential error message')); await assert.rejects(promise,error=>['incomplete-body','request-error','aborted'].includes(error.code)&&!error.message.includes('confidential')); } });