*/ private const MEDIA_TYPES = [ 'stl' => 'model/stl', '3mf' => 'model/3mf', 'gcode' => 'text/x-gcode', ]; /** * @return array{extension: string, media_type: string} */ public function validate(UploadedFile $file): array { $size = $file->getSize(); if ($size === false || $size <= 0) { throw new RuntimeException('File is empty or unreadable.'); } if ($size > self::MAX_FILE_SIZE) { throw new RuntimeException('File exceeds the maximum allowed size of 100 MiB.'); } $extension = strtolower((string) $file->getClientOriginalExtension()); if (! in_array($extension, ProjectFile::EXTENSIONS, true)) { throw new RuntimeException("Unsupported file type: .{$extension}"); } $path = $file->getRealPath() ?: $file->getPathname(); match ($extension) { 'stl' => $this->validateStl($path, $size), '3mf' => $this->validate3mf($path), 'gcode' => $this->validateGcode($path, $size), default => throw new RuntimeException("Unsupported file type: .{$extension}"), }; return ['extension' => $extension, 'media_type' => self::MEDIA_TYPES[$extension]]; } private function validateStl(string $path, int $size): void { if ($size >= 84) { $header = $this->readPrefix($path, 84); if (strlen($header) === 84) { $count = unpack('V', substr($header, 80, 4))[1] ?? 0; if ($count > 0 && $count <= self::MAX_STL_TRIANGLES && $size === 84 + $count * 50) { $this->assertBinaryStlTrianglesFinite($path, $count); return; } } } $prefix = strtolower(ltrim($this->readPrefix($path, 16))); if (! str_starts_with($prefix, 'solid')) { throw new RuntimeException('File does not match the declared STL format.'); } if (! $this->isPlainText($path)) { throw new RuntimeException('ASCII STL contains unexpected binary content.'); } $this->validateAsciiStlGrammar($path); } /** * A real facet/vertex grammar: "solid garbage endsolid" with zero facets, * or any facet missing its outer loop/vertices, is rejected — not just * files that merely start with "solid" and contain "endsolid" somewhere. * Streamed line-by-line so an up-to-100-MiB file never needs to be loaded * into memory at once. */ private function validateAsciiStlGrammar(string $path): void { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('Unable to read uploaded file.'); } try { $first = fgets($handle); if ($first === false || preg_match('/^\s*solid\b/i', $first) !== 1) { throw new RuntimeException('ASCII STL must begin with a "solid" declaration.'); } $facetCount = 0; $state = 'facet_or_end'; $done = false; while (($line = fgets($handle)) !== false) { $trimmed = trim($line); if ($trimmed === '') { continue; } switch ($state) { case 'facet_or_end': if (preg_match('/^endsolid\b/i', $trimmed) === 1) { $done = true; break 2; } if (preg_match('/^facet\s+normal\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) { throw new RuntimeException('ASCII STL contains an unexpected line outside a facet block.'); } $this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'facet normal'); $state = 'outer_loop'; break; case 'outer_loop': if (preg_match('/^outer\s+loop$/i', $trimmed) !== 1) { throw new RuntimeException('Expected "outer loop" after a facet normal.'); } $state = 'vertex1'; break; case 'vertex1': case 'vertex2': case 'vertex3': if (preg_match('/^vertex\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) { throw new RuntimeException('Expected a "vertex x y z" line inside a facet loop.'); } $this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'vertex'); $state = match ($state) { 'vertex1' => 'vertex2', 'vertex2' => 'vertex3', 'vertex3' => 'endloop', }; break; case 'endloop': if (preg_match('/^endloop$/i', $trimmed) !== 1) { throw new RuntimeException('Expected "endloop" after three vertices.'); } $state = 'endfacet'; break; case 'endfacet': if (preg_match('/^endfacet$/i', $trimmed) !== 1) { throw new RuntimeException('Expected "endfacet" to close a facet.'); } $facetCount++; if ($facetCount > self::MAX_STL_TRIANGLES) { throw new RuntimeException('ASCII STL exceeds the maximum supported facet count.'); } $state = 'facet_or_end'; break; } } if (! $done || $state !== 'facet_or_end') { throw new RuntimeException('ASCII STL is missing an endsolid terminator or has an incomplete facet.'); } if ($facetCount < 1) { throw new RuntimeException('ASCII STL must contain at least one facet.'); } } finally { fclose($handle); } } /** * @param list $tokens */ private function assertFiniteTokens(array $tokens, string $context): void { foreach ($tokens as $token) { $value = (float) $token; if (! is_finite($value)) { throw new RuntimeException("ASCII STL {$context} contains a non-finite value."); } } } private function assertBinaryStlTrianglesFinite(string $path, int $count): void { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('Unable to read uploaded file.'); } try { fseek($handle, 84); $remaining = $count; while ($remaining > 0) { $batch = min($remaining, 2000); $bytes = $batch * 50; $chunk = fread($handle, $bytes); if ($chunk === false || strlen($chunk) !== $bytes) { throw new RuntimeException('Binary STL triangle data is truncated.'); } for ($i = 0; $i < $batch; $i++) { $floats = unpack('f12', substr($chunk, $i * 50, 48)); if ($floats === false) { throw new RuntimeException('Binary STL triangle data is malformed.'); } foreach ($floats as $value) { if (! is_finite($value)) { throw new RuntimeException('Binary STL contains a non-finite (NaN/Infinity) normal or vertex value.'); } } } $remaining -= $batch; } } finally { fclose($handle); } } private function validateGcode(string $path, int $size): void { if (! $this->isPlainText($path)) { throw new RuntimeException('G-code file contains unexpected binary content.'); } } private function validate3mf(string $path): void { $zip = new ZipArchive; if ($zip->open($path) !== true) { throw new RuntimeException('Unable to read 3MF archive.'); } try { $count = $zip->count(); if ($count > self::MAX_3MF_ENTRIES) { throw new RuntimeException('3MF archive has too many entries.'); } $totalUncompressed = 0; /** @var array $seenNames */ $seenNames = []; /** @var list $modelParts */ $modelParts = []; /** @var list $relsEntries */ $relsEntries = []; /** @var list $xmlEntries */ $xmlEntries = []; for ($i = 0; $i < $count; $i++) { $stat = $zip->statIndex($i); if ($stat === false) { throw new RuntimeException('Unable to read 3MF archive entry.'); } $name = (string) $stat['name']; if (isset($seenNames[$name])) { throw new RuntimeException("3MF archive contains a duplicate entry path: {$name}."); } $seenNames[$name] = true; $this->assertSafeZipEntryName($name); $this->assertNotSymlinkEntry($zip, $i, $name); if (($stat['encryption_method'] ?? ZipArchive::EM_NONE) !== ZipArchive::EM_NONE) { throw new RuntimeException("3MF archive contains an encrypted entry: {$name}."); } $uncompressed = (int) $stat['size']; $compressed = (int) $stat['comp_size']; $totalUncompressed += $uncompressed; if ($compressed > 0 && ($uncompressed / $compressed) > self::MAX_3MF_RATIO) { throw new RuntimeException('3MF entry has a suspicious compression ratio.'); } if ($compressed === 0 && $uncompressed > 10 * 1024 * 1024) { throw new RuntimeException('3MF entry has a suspicious compression ratio.'); } if (str_ends_with($name, '.model')) { $modelParts[] = $name; } elseif (str_ends_with($name, '.rels')) { $relsEntries[] = $name; } elseif (str_ends_with($name, '.xml')) { $xmlEntries[] = $name; } } if ($totalUncompressed > self::MAX_3MF_TOTAL_UNCOMPRESSED) { throw new RuntimeException('3MF archive exceeds the maximum uncompressed size.'); } if (! in_array('3D/3dmodel.model', $modelParts, true)) { throw new RuntimeException('3MF archive is missing the required 3D model part.'); } if (! in_array('_rels/.rels', $relsEntries, true)) { throw new RuntimeException('3MF archive is missing the required root relationships part.'); } $budget = self::MAX_3MF_TOTAL_PARSED_BYTES; $rootTargets = []; foreach ($relsEntries as $relsName) { $xml = $this->readBoundedZipEntry($zip, $relsName, self::MAX_3MF_XML_ENTRY_BYTES, $budget); $targets = $this->parseRelationships($xml, $relsName); if ($relsName === '_rels/.rels') { $rootTargets = $targets; } } $hasModelRelationship = false; foreach ($rootTargets as $target) { $resolved = ltrim($target, '/'); if (isset($seenNames[$resolved]) && str_ends_with($resolved, '.model')) { $hasModelRelationship = true; } } if (! $hasModelRelationship) { throw new RuntimeException('3MF root relationships do not point to an internal model part.'); } foreach ($xmlEntries as $xmlName) { $xml = $this->readBoundedZipEntry($zip, $xmlName, self::MAX_3MF_XML_ENTRY_BYTES, $budget); $this->assertGenericWellFormedXml($xml, $xmlName); } foreach ($modelParts as $modelName) { $cap = $modelName === '3D/3dmodel.model' ? self::MAX_3MF_MODEL_PART_BYTES : self::MAX_3MF_XML_ENTRY_BYTES; $xml = $this->readBoundedZipEntry($zip, $modelName, $cap, $budget); $this->assertWellFormedModelXml($xml, $modelName); } } finally { $zip->close(); } } /** * Reads a zip entry through a bounded stream rather than trusting the * archive's own declared size, so a size lie in the central directory * can't blow past the per-entry or whole-archive parse budgets. */ private function readBoundedZipEntry(ZipArchive $zip, string $name, int $perEntryCap, int &$remainingBudget): string { $stream = $zip->getStream($name); if ($stream === false) { throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); } $buffer = ''; $total = 0; try { while (! feof($stream)) { $chunk = fread($stream, 65536); if ($chunk === false) { throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); } $total += strlen($chunk); if ($total > $perEntryCap) { throw new RuntimeException("3MF archive entry exceeds its size cap: {$name}."); } $remainingBudget -= strlen($chunk); if ($remainingBudget < 0) { throw new RuntimeException('3MF archive exceeds the total parse budget.'); } $buffer .= $chunk; } } finally { fclose($stream); } return $buffer; } /** * @return list the raw Target attribute values of every Relationship */ private function parseRelationships(string $xml, string $context): array { $document = $this->parseSafeXml($xml, $context, '3MF relationships part'); $targets = []; foreach ($document->getElementsByTagName('Relationship') as $relationship) { if (! $relationship instanceof DOMElement) { continue; } $targetMode = $relationship->getAttribute('TargetMode'); if ($targetMode !== '' && strcasecmp($targetMode, 'External') === 0) { throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}."); } $target = $relationship->getAttribute('Target'); if ($target === '') { throw new RuntimeException("3MF relationship is missing a Target attribute: {$context}."); } if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $target) === 1 || str_contains($target, '..')) { throw new RuntimeException("3MF relationship target is not a safe internal package reference: {$context}."); } $targets[] = $target; } return $targets; } private function assertGenericWellFormedXml(string $xml, string $context): void { $this->parseSafeXml($xml, $context, '3MF XML part'); } private function assertWellFormedModelXml(string $xml, string $context): void { $document = $this->parseSafeXml($xml, $context, '3MF model part'); $root = $document->documentElement; if ($root === null || $root->localName !== 'model' || ! str_starts_with((string) $root->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) { throw new RuntimeException("3MF model part does not have a valid root element in the 3MF core namespace: {$context}."); } $sawResources = false; $sawBuild = false; foreach ($root->childNodes as $child) { if (! $child instanceof DOMElement) { continue; } if ($child->localName === 'resources') { $sawResources = true; } if ($child->localName === 'build') { $sawBuild = true; } } if (! $sawResources || ! $sawBuild) { throw new RuntimeException("3MF model part is missing a valid model root with resources/build elements: {$context}."); } } /** * No DTD loading, no network access, no entity substitution. Any DOCTYPE is * rejected rather than relied upon to be harmless. Uses DOMDocument because * `dom` is already a deploy-runner requirement and XMLReader is not. */ private function parseSafeXml(string $xml, string $context, string $kind): DOMDocument { libxml_use_internal_errors(true); libxml_clear_errors(); libxml_set_external_entity_loader(static fn (): mixed => null); try { $document = new DOMDocument; $loaded = $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); if ($loaded === false || $document->documentElement === null) { throw new RuntimeException("{$kind} is not well-formed XML: {$context}."); } if ($document->doctype !== null) { throw new RuntimeException("{$kind} contains a disallowed DOCTYPE declaration: {$context}."); } return $document; } finally { libxml_clear_errors(); libxml_set_external_entity_loader(null); } } private function assertSafeZipEntryName(string $name): void { if ($name === '' || str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, '\\') || preg_match('/^[A-Za-z]:/', $name) === 1) { throw new RuntimeException('3MF archive contains an unsafe entry path.'); } } /** * Unix "external file attributes" pack the st_mode bits into the top 16 bits. * A symlink entry (S_IFLNK octal 0120000) must never be trusted, even though * nothing in this codebase currently extracts 3MF content to disk — a future * caller doing so should not inherit a symlink-following vulnerability. */ private function assertNotSymlinkEntry(ZipArchive $zip, int $index, string $name): void { $external = $zip->getExternalAttributesIndex($index, $opsys, $attr) ? $attr : null; if ($external === null) { return; } $unixMode = ($external >> 16) & 0xFFFF; if (($unixMode & 0xF000) === 0xA000) { throw new RuntimeException("3MF archive contains a symlink entry: {$name}."); } } private function readPrefix(string $path, int $length): string { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('Unable to read uploaded file.'); } $data = fread($handle, $length); fclose($handle); return $data === false ? '' : $data; } private function isPlainText(string $path): bool { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('Unable to read uploaded file.'); } while (! feof($handle)) { $chunk = fread($handle, 1024 * 1024); if ($chunk === false) { break; } if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $chunk) === 1) { fclose($handle); return false; } } fclose($handle); return true; } }