Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ICAP] Stop reading the response after headers are read #445

Merged
merged 3 commits into from
May 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions l10n/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ OC.L10N.register(
"Saved" : "Guardado",
"Virus detected! Can't upload the file %s" : "¡Virus detectado! No se puede cargar el archivo %s",
"Malware detected" : "Malware detectado",
"A malware or virus was detected, your upload was deleted. In doubt or for details please contact your system administrator" : "Se detectó un malware o virus, su carga se eliminó. En caso de duda o para obtener más detalles, póngase en contacto con el administrador del sistema",
"Greetings {user}," : "Hola, {user}:",
"Sorry, but a malware was detected in a file you tried to upload and it had to be deleted." : "Lo sentimos, pero se ha detectado un malware en un archivo que trata de subir y tuvo que ser eliminado.",
"This email is a notification from {host}. Please, do not reply." : "Esta es una notificación automática de {host}. Sírvase no responderla.",
Expand All @@ -25,12 +26,15 @@ OC.L10N.register(
"Executable" : "Ejecutable",
"Daemon" : "Demonio",
"Daemon (Socket)" : "Demonio (Socket)",
"Daemon (ICAP)" : "Daemon (ICAP)",
"Socket" : "Socket",
"Clamav Socket" : "Clamav Socket",
"Host" : "Servidor",
"Hostname or IP address of Antivirus Host" : "Nombre del host o dirección IP del host del Antivirus.",
"Port" : "Puerto",
"Port number of Antivirus Host, 1-65535" : "Puerto del host del antivirus, 1-65535",
"ICAP request service. Possible values: \"avscan\" for clamav or \"req\" for Kaspersky ScanEngine" : "Servicio de solicitud ICAP. Valores posibles: \"avscan\" para clamav o \"req\" para Kaspersky ScanEngine",
"ICAP response header holding the virus information. Possible values: X-Virus-ID or X-Infection-Found" : "Encabezado de respuesta ICAP, que contiene la información del virus. Valores posibles: X-Virus-ID o X-Infection-Found",
"Path to clamscan" : "Ruta al clamscan",
"Path to clamscan executable" : "Ruta al ejecutable de ClamScan",
"Extra command line options (comma-separated)" : "Opciones extra de la línea de comandos (separadas por comas)",
Expand Down
4 changes: 4 additions & 0 deletions l10n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"Saved" : "Guardado",
"Virus detected! Can't upload the file %s" : "¡Virus detectado! No se puede cargar el archivo %s",
"Malware detected" : "Malware detectado",
"A malware or virus was detected, your upload was deleted. In doubt or for details please contact your system administrator" : "Se detectó un malware o virus, su carga se eliminó. En caso de duda o para obtener más detalles, póngase en contacto con el administrador del sistema",
"Greetings {user}," : "Hola, {user}:",
"Sorry, but a malware was detected in a file you tried to upload and it had to be deleted." : "Lo sentimos, pero se ha detectado un malware en un archivo que trata de subir y tuvo que ser eliminado.",
"This email is a notification from {host}. Please, do not reply." : "Esta es una notificación automática de {host}. Sírvase no responderla.",
Expand All @@ -23,12 +24,15 @@
"Executable" : "Ejecutable",
"Daemon" : "Demonio",
"Daemon (Socket)" : "Demonio (Socket)",
"Daemon (ICAP)" : "Daemon (ICAP)",
"Socket" : "Socket",
"Clamav Socket" : "Clamav Socket",
"Host" : "Servidor",
"Hostname or IP address of Antivirus Host" : "Nombre del host o dirección IP del host del Antivirus.",
"Port" : "Puerto",
"Port number of Antivirus Host, 1-65535" : "Puerto del host del antivirus, 1-65535",
"ICAP request service. Possible values: \"avscan\" for clamav or \"req\" for Kaspersky ScanEngine" : "Servicio de solicitud ICAP. Valores posibles: \"avscan\" para clamav o \"req\" para Kaspersky ScanEngine",
"ICAP response header holding the virus information. Possible values: X-Virus-ID or X-Infection-Found" : "Encabezado de respuesta ICAP, que contiene la información del virus. Valores posibles: X-Virus-ID o X-Infection-Found",
"Path to clamscan" : "Ruta al clamscan",
"Path to clamscan executable" : "Ruta al ejecutable de ClamScan",
"Extra command line options (comma-separated)" : "Opciones extra de la línea de comandos (separadas por comas)",
Expand Down
167 changes: 83 additions & 84 deletions lib/Scanner/ICAPClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,28 +95,13 @@ public function getRequest(string $method, string $service, array $body = [], ar
return $request;
}

public function options(string $service): array {
$request = $this->getRequest('OPTIONS', $service);
$response = $this->send($request);

return $this->parseResponse($response);
}

public function respmod(string $service, array $body = [], array $headers = []): array {
$request = $this->getRequest('RESPMOD', $service, $body, $headers);
$response = $this->send($request);

return $this->parseResponse($response);
}

public function reqmod(string $service, array $body = [], array $headers = []): array {
$request = $this->getRequest('REQMOD', $service, $body, $headers);
$response = $this->send($request);

return $this->parseResponse($response);
return $response;
}

private function send(string $request): string {
private function send(string $request): array {
$this->connect();
// Shut stupid uncontrolled messaging up - we handle errors on our own
if (@\fwrite($this->writeHandle, $request) === false) {
Expand All @@ -125,90 +110,104 @@ private function send(string $request): string {
);
}

# McAfee seems to not properly close the socket once all response bytes are sent to the client
# we use a 10 sec time out on receiving data
\stream_set_timeout($this->writeHandle, 10, 0);
$response = '';
while ($buffer = \fread($this->writeHandle, 2048)) {
$response .= $buffer;
$headers = [];
$resHdr = [];
$protocol = $this->readIcapStatusLine();

// McAfee seems to not properly close the socket once all response bytes are sent to the client
// So if ICAP status is 204 we just stop reading
if ($protocol['code'] !== 204) {
$headers = $this->readHeaders();
if (isset($headers['Encapsulated'])) {
$resHdr = $this->parseResHdr($headers['Encapsulated']);
}
}

$this->disconnect();
return $response;
return [
'protocol' => $protocol,
'headers' => $headers,
'body' => ['res-hdr' => $resHdr]
];
}

private function parseResponse(string $response): array {
$responseArray = [
'protocol' => [],
'headers' => [],
'body' => [],
'rawBody' => ''
private function readIcapStatusLine(): array {
$icapHeader = \trim(\fgets($this->writeHandle));
$numValues = \sscanf($icapHeader, "ICAP/%d.%d %d %s", $v1, $v2, $code, $status);
if ($numValues !== 4) {
throw new RuntimeException("Unknown ICAP response: \"$icapHeader\"");
}
return [
'protocolVersion' => "$v1.$v2",
'code' => $code,
'status' => $status,
];
}

foreach (\preg_split('/\r?\n/', $response) as $line) {
if ($responseArray['protocol'] === []) {
if (\strpos($line, 'ICAP/') !== 0) {
throw new RuntimeException("Unknown ICAP response: \"$response\"");
}

$parts = \preg_split('/\ +/', $line, 3);

$responseArray['protocol'] = [
'icap' => $parts[0] ?? '',
'code' => $parts[1] ?? '',
'message' => $parts[2] ?? '',
];

private function parseResHdr(string $headerValue): array {
$encapsulatedHeaders = [];
$encapsulatedParts = \explode(",", $headerValue);
foreach ($encapsulatedParts as $encapsulatedPart) {
$pieces = \explode("=", \trim($encapsulatedPart));
if ($pieces[1] === "0") {
continue;
}
$rawEncapsulatedHeaders = \fread($this->writeHandle, $pieces[1]);
$encapsulatedHeaders = $this->parseEncapsulatedHeaders($rawEncapsulatedHeaders);
// According to the spec we have a single res-hdr part and are not interested in res-body content
break;
}
return $encapsulatedHeaders;
}

if ($line === '') {
private function readHeaders(): array {
$headers = [];
$prevString = "";
while ($headerString = \fgets($this->writeHandle)) {
$trimmedHeaderString = \trim($headerString);
if ($prevString === "" && $trimmedHeaderString === "") {
break;
}

$parts = \preg_split('/:\ /', $line, 2);
if (isset($parts[0])) {
$responseArray['headers'][$parts[0]] = $parts[1] ?? '';
list($headerName, $headerValue) = $this->parseHeader($trimmedHeaderString);
if ($headerName !== '') {
$headers[$headerName] = $headerValue;
if ($headerName == "Encapsulated") {
break;
}
}
$prevString = $trimmedHeaderString;
}
return $headers;
}

$body = \preg_split('/\r?\n\r?\n/', $response, 2);
if (isset($body[1])) {
$responseArray['rawBody'] = $body[1];

if (\array_key_exists('Encapsulated', $responseArray['headers'])) {
$encapsulated = [];
$params = \explode(", ", $responseArray['headers']['Encapsulated']);

foreach ($params as $param) {
$parts = \explode("=", $param);
if (\count($parts) !== 2) {
continue;
}

$encapsulated[$parts[0]] = $parts[1];
}

foreach ($encapsulated as $section => $offset) {
$data = \substr($body[1], (int)$offset);
switch ($section) {
case 'req-hdr':
case 'res-hdr':
$responseArray['body'][$section] = \preg_split('/\r?\n\r?\n/', $data, 2)[0];
break;

case 'req-body':
case 'res-body':
$parts = \preg_split('/\r?\n/', $data, 2);
if (\count($parts) === 2) {
$responseArray['body'][$section] = \substr($parts[1], 0, \hexdec($parts[0]));
}
break;
}
}
private function parseEncapsulatedHeaders(string $headerString) : array {
$headers = [];
$split = \preg_split('/\r?\n/', \trim($headerString));
$statusLine = \array_shift($split);
if ($statusLine !== null) {
$headers['HTTP_STATUS'] = $statusLine;
}
foreach (\preg_split('/\r?\n/', $headerString) as $line) {
if ($line === '') {
continue;
}
list($name, $value) = $this->parseHeader($line);
if ($name !== '') {
$headers[$name] = $value;
}
}

return $responseArray;
return $headers;
}

private function parseHeader(string $headerString): array {
$name = '';
$value = '';
$parts = \preg_split('/:\ /', $headerString, 2);
if (isset($parts[0])) {
$name = $parts[0];
$value = $parts[1] ?? '';
}
return [$name, $value];
}
}
6 changes: 3 additions & 3 deletions lib/Scanner/ICAPScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@ public function completeAsyncScan() {
], [
'Allow' => 204
]);
$code = $response['protocol']['code'] ?? '500';
if ($code === '200' || $code === '204') {
$code = $response['protocol']['code'] ?? 500;
if ($code === 200 || $code === 204) {
// c-icap/clamav reports this header
$virus = $response['headers'][$this->virusHeader] ?? false;
if ($virus) {
return Status::create(Status::SCANRESULT_INFECTED, $virus);
}

// kaspersky(pre 2020 product editions) and McAfee handling
$respHeader = $response['body']['res-hdr'] ?? '';
$respHeader = $response['body']['res-hdr']['HTTP_STATUS'] ?? '';
if (\strpos($respHeader, '403 Forbidden') || \strpos($respHeader, '403 VirusFound')) {
$message = $this->l10n->t('A malware or virus was detected, your upload was deleted. In doubt or for details please contact your system administrator');
return Status::create(Status::SCANRESULT_INFECTED, $message);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/Scanner/IcapScannerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ protected function setUp(): void {

$logger = $this->createMock(ILogger::class);
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->will($this->returnArgument(0));

# for local testing replace 'icap' with the ip of the clamav instance
$config->method('getAvHost')->willReturn('icap');
Expand Down