logger = $logger; $this->context = $context; $this->detectors = $detectors; $this->config = $config; $this->floodService = $floodService; $this->snifferService = $snifferService; $this->hashService = $hashService; $this->quarantineService = $quarantineService; } private function isModuleEnabled(string $name): bool { if ($this->config instanceof Config) { return $this->config->isModuleEnabled($name); } // Enforce config-only behavior: no config supplied => module disabled return false; } /** * @param array> $files * @param array $server */ public function dispatch(array $files, array $server): void { $method = $this->context->getMethod(); if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) { return; } $ctype = $this->context->getContentType(); $clen = $this->context->getContentLength(); $te = $this->context->getTransferEncoding(); // Raw-body uploads with no multipart if (empty($files)) { $this->handleRawBody($ctype, $clen, $te); } // multipart/form-data but no $_FILES if (empty($files) && $ctype && stripos($ctype, 'multipart/form-data') !== false) { $this->logger->logEvent('multipart_no_files', []); } if (empty($files)) { return; } // Per request flood check if ($this->isModuleEnabled('flood') && $this->floodService !== null) { $reqCount = $this->floodService->check($this->context->getIp()); $floodMax = 40; if ($this->config instanceof Config) { $floodMax = (int)$this->config->get('limits.flood_max_uploads', $floodMax); } if ($reqCount > $floodMax) { $this->logger->logEvent('flood_alert', ['count' => (int)$reqCount]); } } foreach ($files as $file) { if (!isset($file['name'])) { continue; } // Multi upload field: name[] if (is_array($file['name'])) { $count = count($file['name']); for ($i = 0; $i < $count; $i++) { $this->handleFileEntry( (string)($file['name'][$i] ?? ''), (string)($file['type'][$i] ?? ''), (int)($file['size'][$i] ?? 0), (string)($file['tmp_name'][$i] ?? ''), (int)($file['error'][$i] ?? UPLOAD_ERR_NO_FILE) ); } } else { $this->handleFileEntry( (string)$file['name'], (string)($file['type'] ?? ''), (int)($file['size'] ?? 0), (string)($file['tmp_name'] ?? ''), (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) ); } } } private function handleRawBody(string $ctype, int $clen, string $te): void { global $RAW_BODY_MIN, $PEEK_RAW_INPUT, $SNIFF_MAX_FILESIZE, $SNIFF_MAX_BYTES, $BASE64_FINGERPRINT_BYTES; $rawSuspicious = false; if ($clen >= $RAW_BODY_MIN) $rawSuspicious = true; if ($te !== '') $rawSuspicious = true; if (stripos($ctype, 'application/octet-stream') !== false) $rawSuspicious = true; if (stripos($ctype, 'application/json') !== false) $rawSuspicious = true; // Guarded peek into php://input for JSON/base64 payload detection. if ($this->isModuleEnabled('raw_peek') && $PEEK_RAW_INPUT && $clen > 0 && $clen <= $SNIFF_MAX_FILESIZE) { $peek = ''; $in = @fopen('php://input', 'r'); if ($in !== false) { $peek = @stream_get_contents($in, $SNIFF_MAX_BYTES); @fclose($in); } if ($peek !== false && $peek !== '') { $b = $this->isModuleEnabled('base64_detection') && $this->snifferService !== null ? $this->snifferService->detectJsonBase64Head($peek, 1024) : ['found' => false, 'decoded_head' => null, 'reason' => null]; if (!empty($b['found'])) { if ($this->snifferService !== null && $this->snifferService->base64IsAllowlisted($this->context->getUri(), $ctype)) { $this->logger->logEvent('raw_body_base64_ignored', ['uri' => $this->context->getUri(), 'ctype' => $ctype]); } else { $fingerprints = []; if (!empty($b['decoded_head'])) { $decodedHead = $b['decoded_head']; $sample = substr($decodedHead, 0, $BASE64_FINGERPRINT_BYTES); $fingerprints['sha1'] = @sha1($sample); $fingerprints['md5'] = @md5($sample); if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($decodedHead, $ctype)) { $rawSuspicious = true; $this->logger->logEvent('raw_body_php_payload', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', 'fingerprints' => $fingerprints, ]); } else { $this->logger->logEvent('raw_body_base64', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', 'fingerprints' => $fingerprints, ]); } } else { $this->logger->logEvent('raw_body_base64', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', ]); } } } else { if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($peek, $ctype)) { $this->logger->logEvent('raw_body_php_payload', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => 'head_php_markers', ]); $rawSuspicious = true; } } } } if ($rawSuspicious) { $this->logger->logEvent('raw_body', [ 'len' => (int)$clen, 'ctype' => $ctype, ]); } } private function handleFileEntry(string $name, string $type, int $size, string $tmp, int $err): void { global $BLOCK_SUSPICIOUS, $MAX_SIZE, $FLOOD_MAX_UPLOADS; global $QUARANTINE_ENABLED, $ARCHIVE_INSPECT, $ARCHIVE_BLOCK_ON_SUSPICIOUS; if ($err !== UPLOAD_ERR_OK) { $this->logger->logEvent('upload_error', [ 'name' => $name, 'err' => (int)$err, ]); return; } $origName = (string)$name; $name = basename($origName); $type = (string)$type; $size = (int)$size; $tmp = (string)$tmp; // Flood count per file (stronger) if ($this->isModuleEnabled('flood') && $this->floodService !== null) { $count = $this->floodService->check($this->context->getIp()); if ($count > $FLOOD_MAX_UPLOADS) { $this->logger->logEvent('flood_alert', ['count' => (int)$count]); } } $real = $this->snifferService !== null ? $this->snifferService->detectRealMime($tmp) : 'unknown'; // If client-provided MIME `type` is empty, fall back to detected real MIME if ($type === '' || $type === 'unknown') { if (!empty($real) && $real !== 'unknown') { $type = $real; } } $suspicious = false; $reasons = []; if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) { $suspicious = true; $reasons[] = 'bad_name'; } foreach ($this->detectors as $detector) { $detectorName = $detector->getName(); if (!$this->isModuleEnabled($detectorName)) { continue; } $result = $detector->detect($this->context, [ 'name' => $name, 'orig_name' => $origName, 'real_mime' => $real, 'type' => $type, 'tmp' => $tmp, 'size' => $size, ]); if (!empty($result['suspicious'])) { $suspicious = true; if (!empty($result['reasons']) && is_array($result['reasons'])) { $reasons = array_merge($reasons, $result['reasons']); } } } // Hash before any quarantine move $hashes = $this->isModuleEnabled('hashing') && $this->hashService !== null ? $this->hashService->computeHashes($tmp, $size) : []; // Content sniffing for PHP payload (fast head scan, only for small files) if ($this->isModuleEnabled('mime_sniff') && $this->snifferService !== null && $this->snifferService->sniffFileForPhpPayload($tmp)) { $suspicious = true; $reasons[] = 'php_payload'; } // Very large file if ($size > $MAX_SIZE) { $this->logger->logEvent('big_upload', [ 'name' => $name, 'size' => (int)$size, ]); $reasons[] = 'big_file'; } // Archive uploads are higher risk (often used to smuggle payloads) if ($this->snifferService !== null && $this->snifferService->isArchive($name, $real)) { $reasons[] = 'archive'; $this->logger->logEvent('archive_upload', [ 'name' => $name, 'real_mime' => $real, ]); if ($QUARANTINE_ENABLED && $this->isModuleEnabled('quarantine')) { $qres = $this->quarantineService !== null ? $this->quarantineService->quarantineFile($tmp, $origName, $hashes) : ['ok' => false, 'path' => '']; if ($qres['ok']) { $qpath = $qres['path']; $this->logger->logEvent('archive_quarantined', ['path' => $qpath]); if ($this->isModuleEnabled('archive_inspect') && $ARCHIVE_INSPECT) { $inspect = $this->quarantineService !== null ? $this->quarantineService->inspectArchiveQuarantine($qpath) : ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => true]; $this->logger->logEvent('archive_inspect', ['path' => $qpath, 'summary' => $inspect]); if (!empty($inspect['suspicious_entries'])) { $suspicious = true; $reasons[] = 'archive_contains_suspicious'; if ($ARCHIVE_BLOCK_ON_SUSPICIOUS && $BLOCK_SUSPICIOUS) { http_response_code(403); exit('Upload blocked - suspicious archive'); } } } } else { $this->logger->logEvent('archive_quarantine_failed', ['tmp' => $tmp, 'dest' => $qres['path']]); } } } $this->logger->logEvent('upload', [ 'name' => $name, 'orig_name' => $origName, 'size' => (int)$size, 'type' => $type, 'real_mime' => $real, 'tmp' => $tmp, 'hashes' => $hashes, 'flags' => $reasons, ]); if ($suspicious) { $this->logger->logEvent('suspicious_upload', [ 'name' => $name, 'reasons' => $reasons, ]); if ($BLOCK_SUSPICIOUS) { http_response_code(403); exit('Upload blocked'); } } } }