39 lines
5.5 KiB
JavaScript
39 lines
5.5 KiB
JavaScript
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);});
|