Files
safetynet/apps/browser/icap-client.cjs
T

97 lines
7.0 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
'use strict';
// RFC 3507, full-message ICAP only. Adapted objects are never released as originals.
// TLS follows the endpoint trust chain and hostname; deployment credentials stay local.
const net = require('node:net');
const tls = require('node:tls');
const crypto = require('node:crypto');
const HEADER_LIMIT = 32768, ABSOLUTE_LIMIT = 32 * 1024 * 1024;
function configuration(config) {
const url = new URL(config.url);
if (!['icap:', 'icaps:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(config.url)) throw Error('configuration');
const host = url.hostname.replace(/^\[|\]$/g, '');
const loopback = host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.split('.')[0] === '127');
if (url.protocol === 'icap:' && !loopback && config.allowInsecureTransport !== true) throw Error('configuration');
if (!['RESPMOD', 'REQMOD'].includes(config.method || 'RESPMOD')) throw Error('configuration');
const maxBytes = config.maxBytes ?? ABSOLUTE_LIMIT, timeoutMs = config.timeoutMs ?? 10000;
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > ABSOLUTE_LIMIT || !Number.isInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 120000) throw Error('configuration');
const port = Number(url.port || (url.protocol === 'icaps:' ? 11344 : 1344));
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('configuration');
return {url, host, connectHost:host === 'localhost' ? '127.0.0.1' : host, port, method: config.method || 'RESPMOD', maxBytes, timeoutMs, allow204: config.allow204 === true};
}
function headers(bytes) {
const lines = bytes.toString('latin1').split('\r\n');
const match = /^ICAP\/1\.0 ([0-9]{3})(?: [\x20-\x7e]*)?$/.exec(lines.shift());
if (!match) throw Error('protocol');
const values = Object.create(null);
for (const line of lines) {
const header = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)$/.exec(line);
if (!header) throw Error('protocol');
const name = header[1].toLowerCase();
if (Object.hasOwn(values, name)) throw Error('protocol');
values[name] = header[2].trim();
}
return {status: Number(match[1]), values};
}
function exchange(c, request, deadline, credentials = {}) {
return new Promise((resolve, reject) => {
let socket, buffered = Buffer.alloc(0), finished = false, sent = false;
const finish = (error, response) => { if (finished) return; finished = true; clearTimeout(timer); socket?.destroy(); error ? reject(Error('ICAP unavailable')) : resolve(response); };
const timer = setTimeout(() => finish(true), Math.max(1, deadline - Date.now()));
const connect = () => socket.write(request, error => { if (error) finish(true); else sent = true; });
try {
socket = c.url.protocol === 'icaps:' ? tls.connect({host:c.connectHost, port:c.port, servername:net.isIP(c.host) ? undefined : c.host, rejectUnauthorized:true, minVersion:'TLSv1.2', ca:credentials.ca, cert:credentials.cert, key:credentials.key}, connect) : net.connect({host:c.connectHost,port:c.port}, connect);
socket.on('error', () => finish(true));
socket.on('end', () => finish(true));
socket.on('data', chunk => {
try {
if (buffered.length + chunk.length > HEADER_LIMIT) throw Error('protocol');
buffered = Buffer.concat([buffered,chunk]);
const boundary = buffered.indexOf('\r\n\r\n');
if (boundary < 0) return;
const response = headers(buffered.subarray(0,boundary));
// No preview is sent: a premature verdict cannot attest complete inspection.
if (!sent || response.status === 100) throw Error('protocol');
const bodyBytes = buffered.length - boundary - 4;
// Adaptations are blocked immediately; do not consume arbitrary vendor bodies.
if (response.status === 204 && bodyBytes !== 0) throw Error('protocol');
finish(false,response);
} catch { finish(true); }
});
} catch { finish(true); }
});
}
function request(c, method, encapsulation, body = Buffer.alloc(0)) {
const allow = method !== 'OPTIONS' && c.allow204 ? 'Allow: 204\r\n' : '';
return Buffer.concat([Buffer.from(`${method} ${c.url.href} ICAP/1.0\r\nHost: ${c.url.host}\r\nUser-Agent: S-Browser/1\r\nConnection: close\r\n${allow}Encapsulated: ${encapsulation}\r\n\r\n`,'ascii'),body]);
}
async function negotiate(c, deadline, options) {
const response = await exchange(c,request(c,'OPTIONS','null-body=0'),deadline,options);
if (response.status !== 200 || response.values.encapsulated !== 'null-body=0' || !response.values.istag || !response.values.methods?.split(/\s*,\s*/).includes(c.method)) throw Error('unsupported');
return response.values.allow?.split(/\s*,\s*/).includes('204') === true;
}
async function probeIcap(config, options = {}) {
try { const c = configuration(config); const allow204 = await negotiate(c,Date.now()+c.timeoutMs,options); return {ready:!c.allow204 || allow204,engine:'icap',method:c.method,tls:c.url.protocol === 'icaps:',allow204}; }
catch { return {ready:false,engine:'icap'}; }
}
async function scanIcap(input, config, options = {}) {
// Snapshot before any await: the approval is bound to exactly these bytes.
const bytes = Buffer.from(input);
const result = (decision,ruleId,complete) => ({decision,ruleId,engine:'icap',sha256:crypto.createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length,coverage:{complete}});
try {
const c = configuration(config);
if (bytes.length > c.maxBytes) return result('block','icap-oversize',false);
const deadline = Date.now()+c.timeoutMs;
const negotiated = await negotiate(c,deadline,options);
if (c.allow204 && !negotiated) return result('block','icap-capability',false);
// Synthetic metadata avoids sending usernames, private URLs or filenames.
const http = Buffer.from(c.method === 'RESPMOD' ? `HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n` : `POST /scan/object HTTP/1.1\r\nHost: inspection.invalid\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n`,'ascii');
const chunk = bytes.length ? Buffer.concat([Buffer.from(bytes.length.toString(16)+'\r\n'),bytes,Buffer.from('\r\n0\r\n\r\n')]) : Buffer.from('0\r\n\r\n');
const prefix = c.method === 'RESPMOD' ? 'res' : 'req';
const response = await exchange(c,request(c,c.method,`${prefix}-hdr=0, ${prefix}-body=${http.length}`,Buffer.concat([http,chunk])),deadline,options);
const flagged = Object.keys(response.values).some(name => ['x-infection-found','x-virus-id','x-violations-found','x-block-reason'].includes(name));
if (flagged) return result('block','icap-threat',false);
if (response.status === 204 && c.allow204 && negotiated && response.values.istag && (!response.values.encapsulated || response.values.encapsulated === 'null-body=0')) return result('allow','icap-clean',true);
return result('block',response.status === 200 ? 'icap-adaptation' : 'icap-verdict',false);
} catch { return result('block','icap-unavailable',false); }
}
module.exports = {scanIcap,probeIcap};