Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.91% |
269 / 306 |
|
57.89% |
11 / 19 |
CRAP | |
0.00% |
0 / 1 |
| IcapClient | |
87.91% |
269 / 306 |
|
57.89% |
11 / 19 |
71.24 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| forServer | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| create | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| parserFor | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| request | |
72.00% |
18 / 25 |
|
0.00% |
0 / 1 |
2.09 | |||
| executeRaw | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| options | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
5 | |||
| scanFile | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
3 | |||
| scanFileWithPreview | |
93.55% |
29 / 31 |
|
0.00% |
0 / 1 |
6.01 | |||
| scanFileWithPreviewStrict | |
73.91% |
34 / 46 |
|
0.00% |
0 / 1 |
6.64 | |||
| scanFileWithPreviewLegacy | |
98.04% |
50 / 51 |
|
0.00% |
0 / 1 |
5 | |||
| resolvePreviewSize | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| buildServiceUri | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| validateServicePath | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| interpretResponse | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
6.42 | |||
| assertSuccessfulStatus | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| extractVirusName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| validateIcapHeaders | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| mergeHeaders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * SPDX-License-Identifier: EUPL-1.2 |
| 5 | * |
| 6 | * This file is part of icap-flow. |
| 7 | * |
| 8 | * Licensed under the EUPL, Version 1.2 only (the "Licence"); |
| 9 | * you may not use this work except in compliance with the Licence. |
| 10 | * You may obtain a copy of the Licence at: |
| 11 | * |
| 12 | * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 |
| 13 | * |
| 14 | * Unless required by applicable law or agreed to in writing, software |
| 15 | * distributed under the Licence is distributed on an "AS IS" basis, |
| 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 17 | */ |
| 18 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap; |
| 22 | |
| 23 | use Amp\Cancellation; |
| 24 | use Amp\Future; |
| 25 | use Ndrstmr\Icap\Cache\OptionsCacheInterface; |
| 26 | use Ndrstmr\Icap\DTO\HttpResponse; |
| 27 | use Ndrstmr\Icap\DTO\IcapRequest; |
| 28 | use Ndrstmr\Icap\DTO\IcapResponse; |
| 29 | use Ndrstmr\Icap\DTO\ScanResult; |
| 30 | use Ndrstmr\Icap\Exception\IcapClientException; |
| 31 | use Ndrstmr\Icap\Exception\IcapProtocolException; |
| 32 | use Ndrstmr\Icap\Exception\IcapServerException; |
| 33 | use Ndrstmr\Icap\Transport\AsyncAmpTransport; |
| 34 | use Ndrstmr\Icap\Transport\SessionAwareTransport; |
| 35 | use Ndrstmr\Icap\Transport\SynchronousStreamTransport; |
| 36 | use Ndrstmr\Icap\Transport\TransportInterface; |
| 37 | use Psr\Log\LoggerInterface; |
| 38 | use Psr\Log\NullLogger; |
| 39 | |
| 40 | /** |
| 41 | * Core asynchronous ICAP client used by the synchronous wrapper. |
| 42 | */ |
| 43 | final class IcapClient implements IcapClientInterface |
| 44 | { |
| 45 | private PreviewStrategyInterface $previewStrategy; |
| 46 | private LoggerInterface $logger; |
| 47 | |
| 48 | public function __construct( |
| 49 | private Config $config, |
| 50 | private TransportInterface $transport, |
| 51 | private RequestFormatterInterface $formatter, |
| 52 | private ResponseParserInterface $parser, |
| 53 | ?PreviewStrategyInterface $previewStrategy = null, |
| 54 | ?LoggerInterface $logger = null, |
| 55 | private ?OptionsCacheInterface $optionsCache = null, |
| 56 | ) { |
| 57 | $this->previewStrategy = $previewStrategy ?? new DefaultPreviewStrategy( |
| 58 | $config->getVirusFoundHeaders(), |
| 59 | ); |
| 60 | $this->logger = $logger ?? new NullLogger(); |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Convenience factory for synchronous environments. |
| 65 | */ |
| 66 | public static function forServer(string $host, int $port = 1344): self |
| 67 | { |
| 68 | $config = new Config($host, $port); |
| 69 | return new self( |
| 70 | $config, |
| 71 | new SynchronousStreamTransport(), |
| 72 | new RequestFormatter(), |
| 73 | self::parserFor($config), |
| 74 | ); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Factory using the default async transport. |
| 79 | */ |
| 80 | public static function create(): self |
| 81 | { |
| 82 | $config = new Config('127.0.0.1'); |
| 83 | return new self( |
| 84 | $config, |
| 85 | new AsyncAmpTransport(), |
| 86 | new RequestFormatter(), |
| 87 | self::parserFor($config), |
| 88 | new DefaultPreviewStrategy(), |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | private static function parserFor(Config $config): ResponseParser |
| 93 | { |
| 94 | return new ResponseParser( |
| 95 | maxHeaderCount: $config->getMaxHeaderCount(), |
| 96 | maxHeaderLineLength: $config->getMaxHeaderLineLength(), |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | #[\Override] |
| 101 | public function request(IcapRequest $request, ?Cancellation $cancellation = null): Future |
| 102 | { |
| 103 | /** @var Future<ScanResult> $future */ |
| 104 | $future = \Amp\async(function () use ($request, $cancellation): ScanResult { |
| 105 | $context = [ |
| 106 | 'method' => $request->method, |
| 107 | 'uri' => $request->uri, |
| 108 | 'host' => $this->config->host, |
| 109 | 'port' => $this->config->port, |
| 110 | ]; |
| 111 | $this->logger->info('ICAP request started', $context); |
| 112 | |
| 113 | $response = null; |
| 114 | try { |
| 115 | $response = $this->executeRaw($request, $cancellation)->await(); |
| 116 | $result = $this->interpretResponse($response, $this->config); |
| 117 | } catch (\Throwable $e) { |
| 118 | $this->logger->warning('ICAP request failed', $context + [ |
| 119 | 'statusCode' => $response?->statusCode, |
| 120 | 'exception' => $e::class, |
| 121 | 'message' => $e->getMessage(), |
| 122 | ]); |
| 123 | throw $e; |
| 124 | } |
| 125 | |
| 126 | $this->logger->info('ICAP request completed', $context + [ |
| 127 | 'statusCode' => $response->statusCode, |
| 128 | 'infected' => $result->isInfected(), |
| 129 | ]); |
| 130 | |
| 131 | return $result; |
| 132 | }); |
| 133 | |
| 134 | return $future; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Send the ICAP request and return the parsed {@see IcapResponse} |
| 139 | * without the fail-secure status-code interpretation pass. Used |
| 140 | * internally by the preview flow, where `100 Continue` is a |
| 141 | * legitimate intermediate response. |
| 142 | * |
| 143 | * Visibility is intentionally `protected`: this method bypasses the |
| 144 | * fail-secure status-code interpretation in {@see interpretResponse()}, |
| 145 | * so exposing it as part of the public surface would let callers |
| 146 | * silently turn unexpected statuses (e.g. a stray `100` outside the |
| 147 | * preview flow) into a `clean` verdict. External callers must use |
| 148 | * {@see request()} or one of the `scanFile*()` methods instead. |
| 149 | * Subclasses that need raw access (e.g. for vendor-specific extensions) |
| 150 | * can still override or invoke this method. |
| 151 | * |
| 152 | * @return Future<IcapResponse> |
| 153 | */ |
| 154 | protected function executeRaw(IcapRequest $request, ?Cancellation $cancellation = null): Future |
| 155 | { |
| 156 | /** @var Future<IcapResponse> $future */ |
| 157 | $future = \Amp\async(function () use ($request, $cancellation): IcapResponse { |
| 158 | $chunks = $this->formatter->format($request); |
| 159 | $responseString = $this->transport->request($this->config, $chunks, $cancellation)->await(); |
| 160 | return $this->parser->parse($responseString); |
| 161 | }); |
| 162 | |
| 163 | return $future; |
| 164 | } |
| 165 | |
| 166 | #[\Override] |
| 167 | public function options(string $service, ?Cancellation $cancellation = null): Future |
| 168 | { |
| 169 | // Validate first so injection attempts never reach the cache key. |
| 170 | $uri = $this->buildServiceUri($service); |
| 171 | |
| 172 | $cacheKey = $this->config->host . ':' . $this->config->port . $service; |
| 173 | |
| 174 | if ($this->optionsCache !== null) { |
| 175 | $cached = $this->optionsCache->get($cacheKey); |
| 176 | if ($cached !== null) { |
| 177 | $this->assertSuccessfulStatus($cached->statusCode); |
| 178 | return Future::complete($cached); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /** @var Future<IcapResponse> $future */ |
| 183 | $future = \Amp\async(function () use ($uri, $cacheKey, $cancellation): IcapResponse { |
| 184 | $request = new IcapRequest('OPTIONS', $uri); |
| 185 | $context = [ |
| 186 | 'method' => $request->method, |
| 187 | 'uri' => $request->uri, |
| 188 | 'host' => $this->config->host, |
| 189 | 'port' => $this->config->port, |
| 190 | ]; |
| 191 | $this->logger->info('ICAP request started', $context); |
| 192 | |
| 193 | $response = null; |
| 194 | try { |
| 195 | $response = $this->executeRaw($request, $cancellation)->await(); |
| 196 | $this->assertSuccessfulStatus($response->statusCode); |
| 197 | } catch (\Throwable $e) { |
| 198 | $this->logger->warning('ICAP request failed', $context + [ |
| 199 | 'statusCode' => $response?->statusCode, |
| 200 | 'exception' => $e::class, |
| 201 | 'message' => $e->getMessage(), |
| 202 | ]); |
| 203 | throw $e; |
| 204 | } |
| 205 | |
| 206 | $this->logger->info('ICAP request completed', $context + [ |
| 207 | 'statusCode' => $response->statusCode, |
| 208 | ]); |
| 209 | |
| 210 | // Cache the parsed IcapResponse, keyed by host:port + service. |
| 211 | // TTL is taken from the server's Options-TTL header |
| 212 | // (RFC 3507 §4.10.2); fall back to 0 (no caching) when the |
| 213 | // server didn't specify one. |
| 214 | if ($this->optionsCache !== null) { |
| 215 | $ttl = (int) ($response->headers['Options-TTL'][0] ?? '0'); |
| 216 | $istag = $response->headers['ISTag'][0] ?? null; |
| 217 | $this->optionsCache->set($cacheKey, $response, $ttl, $istag); |
| 218 | } |
| 219 | |
| 220 | return $response; |
| 221 | }); |
| 222 | |
| 223 | return $future; |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Scan a local file via RESPMOD by wrapping it in a synthesized HTTP |
| 228 | * response envelope (Content-Type: application/octet-stream, |
| 229 | * Content-Length: <filesize>). The file contents are streamed into |
| 230 | * the encapsulated body, so the full file never lives in memory. |
| 231 | * |
| 232 | * @throws \RuntimeException When the file cannot be opened |
| 233 | */ |
| 234 | #[\Override] |
| 235 | public function scanFile( |
| 236 | string $service, |
| 237 | string $filePath, |
| 238 | array $extraHeaders = [], |
| 239 | ?Cancellation $cancellation = null, |
| 240 | ): Future { |
| 241 | // Validate first so injection attempts never reach the socket. |
| 242 | $this->validateIcapHeaders($extraHeaders); |
| 243 | $uri = $this->buildServiceUri($service); |
| 244 | |
| 245 | $stream = fopen($filePath, 'rb'); |
| 246 | if ($stream === false) { |
| 247 | throw new \RuntimeException('Unable to open file: ' . $filePath); |
| 248 | } |
| 249 | $size = filesize($filePath); |
| 250 | |
| 251 | $httpResponse = new HttpResponse( |
| 252 | statusCode: 200, |
| 253 | headers: [ |
| 254 | 'Content-Type' => ['application/octet-stream'], |
| 255 | 'Content-Length' => [(string) ($size !== false ? $size : 0)], |
| 256 | ], |
| 257 | body: $stream, |
| 258 | ); |
| 259 | |
| 260 | $request = new IcapRequest( |
| 261 | method: 'RESPMOD', |
| 262 | uri: $uri, |
| 263 | headers: $extraHeaders, |
| 264 | encapsulatedResponse: $httpResponse, |
| 265 | ); |
| 266 | |
| 267 | return $this->request($request, $cancellation); |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Scan a local file via RESPMOD using a preview. The first |
| 272 | * {@see $previewSize} bytes are sent along with the Preview / |
| 273 | * Allow: 204 headers; if the file fits entirely within the preview |
| 274 | * the request is terminated with `0; ieof\r\n\r\n` (RFC 3507 §4.5) |
| 275 | * and no continuation round-trip is necessary. |
| 276 | * |
| 277 | * @throws \RuntimeException When the file cannot be opened |
| 278 | */ |
| 279 | #[\Override] |
| 280 | public function scanFileWithPreview( |
| 281 | string $service, |
| 282 | string $filePath, |
| 283 | ?int $previewSize = null, |
| 284 | array $extraHeaders = [], |
| 285 | ?Cancellation $cancellation = null, |
| 286 | ): Future { |
| 287 | if ($previewSize !== null && $previewSize < 1) { |
| 288 | throw new \InvalidArgumentException('Preview size must be >= 1, got: ' . $previewSize); |
| 289 | } |
| 290 | $this->validateIcapHeaders($extraHeaders); |
| 291 | |
| 292 | $previewSize = $this->resolvePreviewSize($service, $previewSize); |
| 293 | |
| 294 | /** @var int<1, max> $previewSize */ |
| 295 | /** @var Future<ScanResult> $future */ |
| 296 | $future = \Amp\async(function () use ($service, $filePath, $previewSize, $extraHeaders, $cancellation): ScanResult { |
| 297 | $fileSize = filesize($filePath); |
| 298 | if ($fileSize === false) { |
| 299 | throw new \RuntimeException('Unable to stat file: ' . $filePath); |
| 300 | } |
| 301 | $stream = fopen($filePath, 'rb'); |
| 302 | if ($stream === false) { |
| 303 | throw new \RuntimeException('Unable to open file: ' . $filePath); |
| 304 | } |
| 305 | |
| 306 | try { |
| 307 | if ($this->transport instanceof SessionAwareTransport) { |
| 308 | // Strict RFC 3507 §4.5 path — preview + continuation |
| 309 | // on the same socket, one logical ICAP request. |
| 310 | return $this->scanFileWithPreviewStrict( |
| 311 | $service, |
| 312 | $stream, |
| 313 | $fileSize, |
| 314 | $previewSize, |
| 315 | $extraHeaders, |
| 316 | $cancellation, |
| 317 | ); |
| 318 | } |
| 319 | // Legacy two-request approximation for non-session |
| 320 | // transports (synchronous, custom impls). |
| 321 | return $this->scanFileWithPreviewLegacy( |
| 322 | $service, |
| 323 | $stream, |
| 324 | $fileSize, |
| 325 | $previewSize, |
| 326 | $extraHeaders, |
| 327 | $cancellation, |
| 328 | ); |
| 329 | } finally { |
| 330 | fclose($stream); |
| 331 | } |
| 332 | }); |
| 333 | |
| 334 | return $future; |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Strict RFC 3507 §4.5 preview-continue: the preview and the |
| 339 | * continuation travel on the same socket as one logical ICAP |
| 340 | * request. Requires a {@see SessionAwareTransport}. |
| 341 | * |
| 342 | * @param resource $stream |
| 343 | * @param array<string, string|string[]> $extraHeaders |
| 344 | */ |
| 345 | private function scanFileWithPreviewStrict( |
| 346 | string $service, |
| 347 | mixed $stream, |
| 348 | int $fileSize, |
| 349 | int $previewSize, |
| 350 | array $extraHeaders, |
| 351 | ?Cancellation $cancellation, |
| 352 | ): ScanResult { |
| 353 | \assert($this->transport instanceof SessionAwareTransport); |
| 354 | \assert($previewSize >= 1); |
| 355 | |
| 356 | $previewBytes = fread($stream, $previewSize); |
| 357 | if ($previewBytes === false) { |
| 358 | throw new \RuntimeException('Unable to read preview from file stream.'); |
| 359 | } |
| 360 | $previewIsComplete = $fileSize <= $previewSize; |
| 361 | |
| 362 | $previewEnvelope = new HttpResponse( |
| 363 | statusCode: 200, |
| 364 | headers: [ |
| 365 | 'Content-Type' => ['application/octet-stream'], |
| 366 | 'Content-Length' => [(string) $fileSize], |
| 367 | ], |
| 368 | body: $previewBytes, |
| 369 | ); |
| 370 | $headers = $this->mergeHeaders($extraHeaders, [ |
| 371 | 'Preview' => [(string) $previewSize], |
| 372 | 'Allow' => ['204'], |
| 373 | ]); |
| 374 | $previewRequest = new IcapRequest( |
| 375 | method: 'RESPMOD', |
| 376 | uri: $this->buildServiceUri($service), |
| 377 | headers: $headers, |
| 378 | encapsulatedResponse: $previewEnvelope, |
| 379 | previewIsComplete: $previewIsComplete, |
| 380 | ); |
| 381 | |
| 382 | $session = $this->transport->openSession($this->config, $cancellation); |
| 383 | try { |
| 384 | $session->write($this->formatter->format($previewRequest)); |
| 385 | $previewIcapResponse = $this->parser->parse($session->readResponse()); |
| 386 | |
| 387 | if ($previewIsComplete) { |
| 388 | $session->release(); |
| 389 | return $this->interpretResponse($previewIcapResponse, $this->config); |
| 390 | } |
| 391 | |
| 392 | $decision = $this->previewStrategy->handlePreviewResponse($previewIcapResponse); |
| 393 | |
| 394 | if ($decision !== PreviewDecision::CONTINUE_SENDING) { |
| 395 | // Server's intermediate response is the verdict — nothing |
| 396 | // to send on this socket. Release back to the pool; |
| 397 | // sockets that have only seen a 100 are still in good |
| 398 | // protocol state. |
| 399 | $session->release(); |
| 400 | return new ScanResult( |
| 401 | isInfected: $decision === PreviewDecision::ABORT_INFECTED, |
| 402 | virusName: $decision === PreviewDecision::ABORT_INFECTED |
| 403 | ? $this->extractVirusName($previewIcapResponse, $this->config) |
| 404 | : null, |
| 405 | originalResponse: $previewIcapResponse, |
| 406 | ); |
| 407 | } |
| 408 | |
| 409 | // §4.5 continuation: ONLY the chunked body remainder, no |
| 410 | // new ICAP head. The original RESPMOD envelope still wraps |
| 411 | // the entire scan. Stream from the current position (past the |
| 412 | // preview bytes) in CHUNK_SIZE blocks — never buffer the |
| 413 | // entire remainder in memory (v2.1.2 OOM fix). |
| 414 | $session->write((new ChunkedBodyEncoder())->encodeRemainderFromStream($stream)); |
| 415 | |
| 416 | $finalIcapResponse = $this->parser->parse($session->readResponse()); |
| 417 | $session->release(); |
| 418 | return $this->interpretResponse($finalIcapResponse, $this->config); |
| 419 | } catch (\Throwable $e) { |
| 420 | // The exchange went off-script — never offer this socket |
| 421 | // back to the pool, the next user could see misaligned |
| 422 | // bytes. |
| 423 | $session->close(); |
| 424 | throw $e; |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Two-request approximation of RFC 3507 §4.5 used when the |
| 430 | * transport doesn't expose a {@see SessionAwareTransport} surface |
| 431 | * (synchronous transport, custom implementations). Works against |
| 432 | * permissive servers but spends a second TCP/TLS handshake. |
| 433 | * |
| 434 | * @param resource $stream |
| 435 | * @param array<string, string|string[]> $extraHeaders |
| 436 | */ |
| 437 | private function scanFileWithPreviewLegacy( |
| 438 | string $service, |
| 439 | mixed $stream, |
| 440 | int $fileSize, |
| 441 | int $previewSize, |
| 442 | array $extraHeaders, |
| 443 | ?Cancellation $cancellation, |
| 444 | ): ScanResult { |
| 445 | \assert($previewSize >= 1); |
| 446 | |
| 447 | $previewBytes = fread($stream, $previewSize); |
| 448 | if ($previewBytes === false) { |
| 449 | throw new \RuntimeException('Unable to read preview from file stream.'); |
| 450 | } |
| 451 | $previewIsComplete = $fileSize <= $previewSize; |
| 452 | |
| 453 | $previewEnvelope = new HttpResponse( |
| 454 | statusCode: 200, |
| 455 | headers: [ |
| 456 | 'Content-Type' => ['application/octet-stream'], |
| 457 | 'Content-Length' => [(string) $fileSize], |
| 458 | ], |
| 459 | body: $previewBytes, |
| 460 | ); |
| 461 | $headers = $this->mergeHeaders($extraHeaders, [ |
| 462 | 'Preview' => [(string) $previewSize], |
| 463 | 'Allow' => ['204'], |
| 464 | ]); |
| 465 | $previewRequest = new IcapRequest( |
| 466 | method: 'RESPMOD', |
| 467 | uri: $this->buildServiceUri($service), |
| 468 | headers: $headers, |
| 469 | encapsulatedResponse: $previewEnvelope, |
| 470 | previewIsComplete: $previewIsComplete, |
| 471 | ); |
| 472 | |
| 473 | $previewIcapResponse = $this->executeRaw($previewRequest, $cancellation)->await(); |
| 474 | |
| 475 | if ($previewIsComplete) { |
| 476 | return $this->interpretResponse($previewIcapResponse, $this->config); |
| 477 | } |
| 478 | |
| 479 | $decision = $this->previewStrategy->handlePreviewResponse($previewIcapResponse); |
| 480 | if ($decision !== PreviewDecision::CONTINUE_SENDING) { |
| 481 | return new ScanResult( |
| 482 | isInfected: $decision === PreviewDecision::ABORT_INFECTED, |
| 483 | virusName: $decision === PreviewDecision::ABORT_INFECTED |
| 484 | ? $this->extractVirusName($previewIcapResponse, $this->config) |
| 485 | : null, |
| 486 | originalResponse: $previewIcapResponse, |
| 487 | ); |
| 488 | } |
| 489 | |
| 490 | rewind($stream); |
| 491 | $fullResponse = new HttpResponse( |
| 492 | statusCode: 200, |
| 493 | headers: [ |
| 494 | 'Content-Type' => ['application/octet-stream'], |
| 495 | 'Content-Length' => [(string) $fileSize], |
| 496 | ], |
| 497 | body: $stream, |
| 498 | ); |
| 499 | $fullRequest = new IcapRequest( |
| 500 | method: 'RESPMOD', |
| 501 | uri: $this->buildServiceUri($service), |
| 502 | headers: $extraHeaders, |
| 503 | encapsulatedResponse: $fullResponse, |
| 504 | ); |
| 505 | return $this->request($fullRequest, $cancellation)->await(); |
| 506 | } |
| 507 | |
| 508 | private const int DEFAULT_PREVIEW_SIZE = 1024; |
| 509 | |
| 510 | /** |
| 511 | * Resolve the effective preview size. When the caller passes null, |
| 512 | * the OPTIONS cache is consulted for the server's advertised |
| 513 | * `Preview` header (RFC 3507 §4.10.2). Falls back to |
| 514 | * {@see DEFAULT_PREVIEW_SIZE} when no cache is configured, the |
| 515 | * cache has no entry, or the cached response lacks a `Preview` |
| 516 | * header. |
| 517 | * |
| 518 | * @return int<1, max> |
| 519 | */ |
| 520 | private function resolvePreviewSize(string $service, ?int $previewSize): int |
| 521 | { |
| 522 | if ($previewSize !== null) { |
| 523 | /** @var int<1, max> $previewSize */ |
| 524 | return $previewSize; |
| 525 | } |
| 526 | |
| 527 | if ($this->optionsCache !== null) { |
| 528 | $cacheKey = $this->config->host . ':' . $this->config->port . $service; |
| 529 | $cached = $this->optionsCache->get($cacheKey); |
| 530 | if ($cached !== null && isset($cached->headers['Preview'][0])) { |
| 531 | $advertised = (int) $cached->headers['Preview'][0]; |
| 532 | if ($advertised >= 1) { |
| 533 | /** @var int<1, max> $advertised */ |
| 534 | return $advertised; |
| 535 | } |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | return self::DEFAULT_PREVIEW_SIZE; |
| 540 | } |
| 541 | |
| 542 | private function buildServiceUri(string $service): string |
| 543 | { |
| 544 | $this->validateServicePath($service); |
| 545 | $host = $this->config->host; |
| 546 | if ($this->config->port !== 1344) { |
| 547 | $host .= ':' . $this->config->port; |
| 548 | } |
| 549 | return sprintf('icap://%s%s', $host, $service); |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * Guard $service against header/URI injection. Finding H of the |
| 554 | * consolidated review: user-controlled service paths can sneak |
| 555 | * CR/LF into the request line and inject additional ICAP headers |
| 556 | * before any wire byte has been written. |
| 557 | * |
| 558 | * RFC 3507 §4.2 allows only the abs_path production for the |
| 559 | * service; we enforce a conservative subset (no controls, no |
| 560 | * whitespace, no NUL) which is sufficient for every known ICAP |
| 561 | * server while leaving segment separators like '/' untouched. |
| 562 | */ |
| 563 | private function validateServicePath(string $service): void |
| 564 | { |
| 565 | if (preg_match('/[\x00-\x20\x7F]/', $service) === 1) { |
| 566 | throw new \InvalidArgumentException( |
| 567 | 'Service path contains control characters, whitespace or NUL; refusing to build ICAP URI: ' . var_export($service, true), |
| 568 | ); |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | /** |
| 573 | * Map an ICAP response to a {@see ScanResult} or raise a typed |
| 574 | * exception. Status-code handling follows RFC 3507 §4.3.3 and the |
| 575 | * de-facto vendor conventions (§7 of the consolidated review). |
| 576 | * |
| 577 | * Security note (finding G): 100 Continue is NOT a finish state |
| 578 | * and MUST NOT be mapped to a clean scan. Outside a preview the |
| 579 | * 100 is a protocol error; inside a preview the caller handles it |
| 580 | * via the {@see PreviewStrategyInterface} before this method sees |
| 581 | * the response. |
| 582 | */ |
| 583 | private function interpretResponse(IcapResponse $response, Config $config): ScanResult |
| 584 | { |
| 585 | $code = $response->statusCode; |
| 586 | |
| 587 | if ($code === 204) { |
| 588 | return new ScanResult(false, null, $response); |
| 589 | } |
| 590 | |
| 591 | if ($code === 200 || $code === 206) { |
| 592 | // 206 Partial Content — some vendors return this when the |
| 593 | // encapsulated response was modified but not fully rewritten |
| 594 | // (RFC 3507 §4.3.3). Virus signalling is the same as 200. |
| 595 | $virus = $this->extractVirusName($response, $config); |
| 596 | if ($virus !== null) { |
| 597 | return new ScanResult(true, $virus, $response); |
| 598 | } |
| 599 | return new ScanResult(false, null, $response); |
| 600 | } |
| 601 | |
| 602 | $this->assertSuccessfulStatus($code); |
| 603 | |
| 604 | // Fail-secure backstop: any status that is neither a clean-scan |
| 605 | // signal (204/200/206) nor a recognised failure (100/4xx/5xx) — |
| 606 | // e.g. 1xx other than 100, 3xx, 6xx+ — is a protocol violation. |
| 607 | throw new IcapProtocolException( |
| 608 | 'Unexpected ICAP status: ' . $code, |
| 609 | $code, |
| 610 | ); |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * Apply the fail-secure verdict for status codes that do not signal a |
| 615 | * successful exchange. Used by {@see interpretResponse()} for scans and |
| 616 | * directly by {@see options()}, which returns the raw {@see IcapResponse} |
| 617 | * on success but must still treat 4xx/5xx/100 as exceptions. |
| 618 | * |
| 619 | * @throws IcapProtocolException When the response is `100 Continue` |
| 620 | * outside the preview flow |
| 621 | * @throws IcapClientException When the status is in the 4xx range |
| 622 | * @throws IcapServerException When the status is in the 5xx range |
| 623 | */ |
| 624 | private function assertSuccessfulStatus(int $code): void |
| 625 | { |
| 626 | if ($code === 100) { |
| 627 | throw new IcapProtocolException( |
| 628 | 'ICAP 100 Continue is only valid during a preview exchange; received outside preview flow.', |
| 629 | $code, |
| 630 | ); |
| 631 | } |
| 632 | |
| 633 | if ($code >= 400 && $code < 500) { |
| 634 | throw new IcapClientException( |
| 635 | sprintf('ICAP client error (%d) — request rejected by server.', $code), |
| 636 | $code, |
| 637 | ); |
| 638 | } |
| 639 | |
| 640 | if ($code >= 500 && $code < 600) { |
| 641 | throw new IcapServerException( |
| 642 | sprintf('ICAP server error (%d) — server failed to service the request.', $code), |
| 643 | $code, |
| 644 | ); |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | private function extractVirusName(IcapResponse $response, Config $config): ?string |
| 649 | { |
| 650 | foreach ($config->getVirusFoundHeaders() as $header) { |
| 651 | if (isset($response->headers[$header][0])) { |
| 652 | return $response->headers[$header][0]; |
| 653 | } |
| 654 | } |
| 655 | return null; |
| 656 | } |
| 657 | |
| 658 | /** |
| 659 | * Reject header names or values that contain CR/LF, NUL or would |
| 660 | * otherwise break the wire format. Finding H, applied to |
| 661 | * user-supplied ICAP headers in addition to the service path. |
| 662 | * |
| 663 | * @param array<string, string|string[]> $headers |
| 664 | */ |
| 665 | private function validateIcapHeaders(array $headers): void |
| 666 | { |
| 667 | foreach ($headers as $name => $value) { |
| 668 | // RFC 7230 §3.2.6 — header names must consist of tchar only: |
| 669 | // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" |
| 670 | // / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA |
| 671 | if (preg_match('/^[!#$%&\'*+\-.^_`|~0-9a-zA-Z]+$/', $name) !== 1) { |
| 672 | throw new \InvalidArgumentException( |
| 673 | 'ICAP header name contains invalid characters (RFC 7230 §3.2.6): ' . var_export($name, true), |
| 674 | ); |
| 675 | } |
| 676 | foreach ((array) $value as $v) { |
| 677 | if (preg_match('/[\x00\r\n]/', $v) === 1) { |
| 678 | throw new \InvalidArgumentException( |
| 679 | sprintf('ICAP header %s carries a value with CR/LF/NUL; refusing to send.', $name), |
| 680 | ); |
| 681 | } |
| 682 | } |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | /** |
| 687 | * Merge caller-supplied headers with library-managed headers. The |
| 688 | * library-managed map always wins, keeping protocol-critical |
| 689 | * headers (Preview, Allow, Encapsulated, Host) out of caller |
| 690 | * control. |
| 691 | * |
| 692 | * @param array<string, string|string[]> $caller |
| 693 | * @param array<string, string[]> $managed |
| 694 | * @return array<string, string[]> |
| 695 | */ |
| 696 | private function mergeHeaders(array $caller, array $managed): array |
| 697 | { |
| 698 | $merged = []; |
| 699 | foreach ($caller as $name => $value) { |
| 700 | $merged[$name] = (array) $value; |
| 701 | } |
| 702 | foreach ($managed as $name => $value) { |
| 703 | $merged[$name] = $value; |
| 704 | } |
| 705 | return $merged; |
| 706 | } |
| 707 | } |