'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};