ndrstmr / icap-flow
State-of-the-art, async-ready ICAP client for PHP.
Requires
- php: ^8.4
- amphp/socket: ^2.3
- psr/log: ^3.0
- revolt/event-loop: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- mockery/mockery: ^1.6
- pestphp/pest: ^3.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.2
- psr/cache: ^3.0
- psr/simple-cache: ^3.0
- roave/security-advisories: dev-latest
Suggests
- psr/cache: Required for Psr6OptionsCache adapter (PSR-6 CacheItemPoolInterface)
- psr/simple-cache: Required for Psr16OptionsCache adapter (PSR-16 CacheInterface)
This package is auto-updated.
Last update: 2026-05-05 16:00:29 UTC
README
icap-flow
An async-ready ICAP (Internet Content Adaptation Protocol) client for PHP 8.4+, focused on RFC 3507 correctness, fail-secure semantics and a small surface area that is comfortable to drop into a Symfony / Laravel / framework-less code base.
Warning
AI-assisted origin & production-use disclaimer. Large parts of this library — code, tests, docs and CI plumbing — were authored with substantial AI assistance, captured under three independent due-diligence reviews in docs/review/. The v2 line closes the protocol- and security-blocking findings of those reviews, but a piece of code that scans uploads for malware sits in the security-critical path of any application that uses it. Do not deploy this in production without a deep, independent review and an integration-test bake-out against the ICAP server you actually use. A non-exhaustive checklist:
- End-to-end test against your production ICAP vendor (c-icap, Symantec, Trend Micro, McAfee Web Gateway, Sophos, Kaspersky, …) — wire formats vary in subtle ways.
- Fail-secure verification: confirm an unreachable / 5xx / malformed-response server makes your application block the upload, not silently pass it through.
- TLS configuration review (cipher policy, hostname verification, cert pinning where appropriate).
- Resource limits (
Config::withLimits(...)) tuned to your traffic profile. - Audit logging via PSR-3 wired into your central log pipeline.
This software is provided AS IS under EUPL-1.2; the licence's "no warranty" clauses apply unconditionally.
What changed in v3
v3.0.0 is a small, deliberate breaking release. It carries no new features — it is a cleanup pass that closes three known-stale corners of the v2 API so the surface can be frozen for the upcoming Symfony bundle (ndrstmr/icap-flow-bundle):
IcapClient::executeRaw()is nowprotected. It was always meant as an internal seam for the preview flow and never appeared inIcapClientInterface. Keeping it public let external callers bypass the fail-secure status-code interpretation. External callers must userequest(),scanFile(),scanFileWithPreview()oroptions(); subclasses keep raw access.options()returnsFuture<IcapResponse>(wasFuture<ScanResult>). OPTIONS is capability discovery, not a virus verdict — callers want the headers (Preview,Options-TTL,Methods,Allow,Service,ISTag,Max-Connections) directly. Fail-secure is preserved: 4xx still throwsIcapClientException, 5xx still throwsIcapServerException,100 Continuestill throwsIcapProtocolException— extracted into a singleassertSuccessfulStatus()helper shared betweeninterpretResponse()andoptions().IcapResponseExceptionis removed. Deprecated since v2.0 (#[\Deprecated]since v2.2). Both throw sites (IcapClient::interpretResponse()backstop,DefaultPreviewStrategy::handlePreviewResponse()defaultbranch) now throwIcapProtocolException. Callers usingcatch (IcapExceptionInterface $e)are unaffected.
Migration: docs/migration-v2-to-v3.md. For most call sites the upgrade is a no-op other than bumping the constraint to ^3.0.
What changed in v2
v2.0.0 was a breaking release that fixes RFC-3507-blocking bugs in v1. The v1 line is deprecated. Highlights:
- RFC-3507 wire format is correct: real
Encapsulatedoffsets, HTTP-in-ICAP nesting, chunked bodies for both string and stream payloads,0; ieofterminator on preview-complete. - Streaming-safe preview —
scanFileWithPreview()no longer buffers the file; only the preview window is read. - Fail-secure on status 100 — a stray 100 outside the preview flow now throws
IcapProtocolExceptioninstead of silently mapping to a clean scan. - CRLF / header-injection guard on
$serviceand on user-supplied ICAP headers. - TLS / icaps:// support via amphp's
ClientTlsContext. - DoS limits —
maxResponseSize,maxHeaderCount,maxHeaderLineLength. - Full status-code matrix — 4xx →
IcapClientException, 5xx →IcapServerException, 206 inspected, … - Multi-vendor virus headers — Config takes an ordered list (
X-Virus-Name,X-Infection-Found,X-Violations-Found,X-Virus-ID). - PSR-3 logger optional, structured events on every request.
- Custom request headers (
X-Client-IP,X-Authenticated-User) onscanFile()/scanFileWithPreview(). - External cancellation — every public method takes an optional
Amp\Cancellation. - OPTIONS-response cache with
Options-TTLhonour. RetryingIcapClientdecorator with exponential backoff for 5xx.- Encapsulated-aware response framing — no dependency on
Connection: close; servers may keep the socket open. - PHP 8.4 minimum; PHP 8.5 in CI; integration tested end-to-end against
mnemoshare/clamav-icap(c-icap 0.6.3 + ClamAV).
The migration guide is docs/migration-v1-to-v2.md. The full per-finding closure list is in docs/review/consolidated_task-list.md.
v2.1.0 added keep-alive connection pooling and strict RFC 3507 §4.5 preview-continue (preview + continuation on the same socket). v2.2.0 added OPTIONS-driven pool tuning (
Max-Connections), pool idle eviction, ISTag-based cache invalidation, PSR-6/PSR-16 OPTIONS-cache adapters and a per-IO timeout model. SeeCHANGELOG.md.
Installation
composer require ndrstmr/icap-flow:^3.0
Quickstart — synchronous
use Ndrstmr\Icap\SynchronousIcapClient; $icap = SynchronousIcapClient::create(); $result = $icap->scanFile('/avscan', '/path/to/upload.bin'); echo $result->isInfected() ? 'Virus found: ' . $result->getVirusName() . PHP_EOL : 'File is clean' . PHP_EOL;
Quickstart — asynchronous (amphp v3 / Revolt)
use Ndrstmr\Icap\IcapClient; use Revolt\EventLoop; $icap = IcapClient::create(); EventLoop::run(function () use ($icap) { $future = $icap->scanFile('/avscan', '/path/to/upload.bin'); $result = $future->await(); echo $result->isInfected() ? 'Virus: ' . $result->getVirusName() . PHP_EOL : 'Clean' . PHP_EOL; });
Configuration
use Amp\Socket\ClientTlsContext; use Ndrstmr\Icap\Config; use Ndrstmr\Icap\IcapClient; use Ndrstmr\Icap\RequestFormatter; use Ndrstmr\Icap\ResponseParser; use Ndrstmr\Icap\Transport\AsyncAmpTransport; $config = (new Config( host: 'icap.example.com', port: 11344, // 1344 is plain ICAP, 11344 is the de-facto TLS port socketTimeout: 5.0, streamTimeout: 30.0, )) ->withTlsContext(new ClientTlsContext('icap.example.com')) ->withVirusFoundHeaders([ 'X-Virus-Name', // ClamAV / c-icap 'X-Infection-Found', // ISS Proventia 'X-Violations-Found', // Trend Micro 'X-Virus-ID', // Symantec ]) ->withLimits( maxResponseSize: 10 * 1024 * 1024, maxHeaderCount: 100, maxHeaderLineLength: 8192, ); $client = new IcapClient( $config, new AsyncAmpTransport(), new RequestFormatter(), new ResponseParser( maxHeaderCount: $config->getMaxHeaderCount(), maxHeaderLineLength: $config->getMaxHeaderLineLength(), ), null, // PreviewStrategyInterface (DefaultPreviewStrategy if null) $logger, // Psr\Log\LoggerInterface (NullLogger if null) );
Connection pool
For long-running workers (RoadRunner, Swoole, ReactPHP) the async transport can reuse sockets via a connection pool:
use Ndrstmr\Icap\Cache\InMemoryOptionsCache; use Ndrstmr\Icap\Transport\AmpConnectionPool; use Ndrstmr\Icap\Transport\AsyncAmpTransport; $pool = new AmpConnectionPool( maxConnectionsPerHost: 8, // idle-socket cap per host:port:tls maxIdleSeconds: 30.0, // evict sockets idle longer than 30 s ); $transport = new AsyncAmpTransport($pool); // Optional: pass an OPTIONS cache so the client auto-negotiates // preview size and honours Options-TTL / ISTag invalidation. $cache = new InMemoryOptionsCache(); // For cross-process caching (Redis, APCu) use Psr16OptionsCache // or Psr6OptionsCache instead. $client = new IcapClient($config, $transport, new RequestFormatter(), new ResponseParser(), optionsCache: $cache);
Custom request headers
$result = $icap->scanFile('/avscan', '/path/to/upload.bin', [ 'X-Client-IP' => '203.0.113.5', 'X-Authenticated-User' => base64_encode('user@example.org'), ]);
Header names and values are validated against CR / LF / NUL / control characters — injection attempts raise InvalidArgumentException before any byte hits the socket. Library-managed headers (Encapsulated, Host, Connection, and inside the preview flow Preview / Allow) always take precedence over caller-supplied values.
Exception taxonomy
Every exception this library throws implements Ndrstmr\Icap\Exception\IcapExceptionInterface so you can catch the whole family in one block.
| Exception | Trigger |
|---|---|
IcapConnectionException |
TCP-level failure (refused, timeout, TLS handshake, ...) |
IcapTimeoutException |
Stream cancellation timed out |
IcapProtocolException |
RFC-3507 violation (e.g. status 100 outside preview, malformed Encapsulated) |
IcapMalformedResponseException (extends IcapProtocolException) |
Server response can't be parsed (no separator, header line without :, oversize lines) |
IcapClientException |
ICAP 4xx response — request rejected by server, code is the real status |
IcapServerException |
ICAP 5xx response — server failed, code is the real status |
IcapResponseException |
Status code that doesn't fit any other bucket |
Examples
The examples/ directory has runnable demos, including a full Symfony-ready async cookbook:
examples/01-sync-scan.php— minimal synchronous scanexamples/02-async-scan.php— async scan insideRevolt\EventLoopexamples/cookbook/01-custom-headers.php—X-Client-IP,X-Authenticated-Userexamples/cookbook/02-custom-preview-strategy.php— vendor-specific preview interpretationexamples/cookbook/03-options-request.php— capability discovery via OPTIONSexamples/cookbook/04-tls-mtls.php— TLS and mutual TLS (mTLS) setupexamples/cookbook/05-retry-decorator.php— exponential-backoff retry on 5xxexamples/cookbook/06-pool-tuning.php— connection-pool idle eviction and Max-Connectionsexamples/cookbook/07-cancellation-from-upload.php— timeout and user-initiated cancellation
Integration tests
A docker-compose stack (docker-compose.yml) brings up mnemoshare/clamav-icap on port 1344. The tests in tests/Integration/ skip when ICAP_HOST is unset, so contributors without Docker get a green composer test:integration while CI exercises a real wire-level round trip on every PR.
docker compose up -d
ICAP_HOST=127.0.0.1 ICAP_PORT=1344 \
ICAP_ECHO_SERVICE=/avscan \
ICAP_CLAMAV_SERVICE=/avscan \
composer test:integration
Provenance & due diligence
docs/review/ carries the three independent due-diligence reports (Claude, Codex, Jules) that drove the v2 redesign, and a verified consolidated task list. They are part of the repo, not after-the-fact marketing — every closed finding maps back to a specific file/line in those docs.
Developers
composer test # unit suite (Pest) composer test:integration # against a configured ICAP server composer stan # PHPStan level 9 + bleedingEdge composer cs-check # PSR-12 (php-cs-fixer) composer cs-fix # apply fixes composer audit # composer + roave/security-advisories
CI matrix: PHP 8.4 + 8.5. See CONTRIBUTING.md for the PR workflow.
Licence
EUPL-1.2 — see LICENSE. The licence is OpenCoDE-compatible and explicitly designed for European public-sector software.
Security
To report a vulnerability, see SECURITY.md. Please do not open a public GitHub issue for security findings.
Changelog
CHANGELOG.md — Keep a Changelog format, SemVer-committed.