import test from 'node:test'; import assert from 'node:assert/strict'; import net from 'node:net'; import tls from 'node:tls'; import fs from 'node:fs'; import {createRequire} from 'node:module'; const {scanIcap,probeIcap}=createRequire(import.meta.url)('../apps/browser/icap-client.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,{secure=false,response='ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n',allow=true,methods='RESPMOD, REQMOD',fragment=false}={}) { const requests=[]; const sockets=new Set(); const handler=socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));let input=Buffer.alloc(0),done=false; socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);const boundary=input.indexOf('\r\n\r\n');if(done||boundary<0)return; const options=input.subarray(0,7).toString()==='OPTIONS'; if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return; done=true;requests.push(Buffer.from(input)); const output=options?`ICAP/1.0 200 OK\r\nMethods: ${methods}\r\nISTag: "fixture"\r\n${allow?'Allow: 204\r\n':''}Encapsulated: null-body=0\r\n\r\n`:response; if(output===null)return; if(fragment){socket.write(output.slice(0,18));setTimeout(()=>socket.end(output.slice(18)),5);}else socket.end(output); });}; const server=secure?tls.createServer({key,cert},handler):net.createServer(handler); await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); t.after(async()=>{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}); return {requests,config:{url:`${secure?'icaps':'icap'}://127.0.0.1:${server.address().port}/av`,method:'RESPMOD',allow204:true,timeoutMs:500,maxBytes:1024}}; } test('full-message fragmented ICAP exchange allows unchanged bytes and omits private metadata',async t=>{ const f=await fixture(t,{fragment:true});const bytes=Buffer.from('public fixture');const r=await scanIcap(bytes,f.config); assert.equal(r.decision,'allow');assert.equal(r.coverage.complete,true);assert.equal(r.bytes,bytes.length); const scan=f.requests[1].toString();assert.match(scan,/RESPMOD .* ICAP\/1.0/);assert.match(scan,/Allow: 204/);assert.match(scan,/Encapsulated: res-hdr=0, res-body=\d+/);assert.ok(scan.endsWith('e\r\npublic fixture\r\n0\r\n\r\n'));assert.ok(!scan.includes('Preview:')); }); test('REQMOD has genuine request-header and request-body sections',async t=>{const f=await fixture(t);f.config.method='REQMOD';assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'allow');assert.match(f.requests[1].toString(),/req-hdr=0, req-body=\d+/);assert.match(f.requests[1].toString(),/POST \/scan\/object HTTP\/1.1/);}); test('server adaptations and threat headers block the original',async t=>{for(const response of ['ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden','ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\nX-Infection-Found: Type=0; Threat=fixture;\r\n\r\n']){const f=await fixture(t,{response});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');}}); test('204 requires explicit signed opt-in and server capability agreement',async t=>{const f=await fixture(t);f.config.allow204=false;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');const g=await fixture(t,{allow:false});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).ruleId,'icap-capability');assert.equal(g.requests.length,1);}); test('unsupported service and malformed duplicate headers fail closed',async t=>{const f=await fixture(t,{methods:'REQMOD'});assert.equal((await probeIcap(f.config)).ready,false);const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nISTag: "one"\r\nISTag: "two"\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);}); test('deadline destroys stalled sockets, oversized content is never transmitted',async t=>{const f=await fixture(t,{response:null});f.config.timeoutMs=60;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-unavailable');f.config.maxBytes=2;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-oversize');}); test('ICAPS validates CA and hostname and never accepts untrusted certificates',async t=>{const f=await fixture(t,{secure:true});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');assert.equal((await scanIcap(Buffer.from('abc'),f.config,{ca:cert})).decision,'allow');assert.equal((await probeIcap(f.config,{ca:cert})).tls,true);}); test('URL credentials, query strings and nonloopback plaintext require safe configuration',async()=>{for(const url of ['icap://user:secret@localhost/av','icap://localhost/av?token=secret','icap://example.com/av','icap://127.evil.example/av','icap://localhost:0/av','http://localhost/av'])assert.equal((await scanIcap(Buffer.from('abc'),{url})).decision,'block');}); test('input mutation cannot alter bytes during negotiation and malformed oversized headers block',async t=>{const f=await fixture(t);const input=Buffer.from('abc');const pending=scanIcap(input,f.config);input.fill(120);assert.equal((await pending).decision,'allow');assert.ok(f.requests[1].includes(Buffer.from('3\r\nabc\r\n0\r\n\r\n')));const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nX-Excess: '+ 'a'.repeat(33000)+'\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);});