diff --git a/app/Services/ManufacturingFileValidator.php b/app/Services/ManufacturingFileValidator.php index 284b3fd..e7191ec 100644 --- a/app/Services/ManufacturingFileValidator.php +++ b/app/Services/ManufacturingFileValidator.php @@ -3,9 +3,10 @@ namespace App\Services; use App\Models\ProjectFile; +use DOMDocument; +use DOMElement; use Illuminate\Http\UploadedFile; use RuntimeException; -use XMLReader; use ZipArchive; /** @@ -20,14 +21,16 @@ use ZipArchive; * every normal/vertex component so a NaN/Infinity payload is rejected. * - 3MF: a bounded zip reader (entry count, per-entry and total uncompressed * size, compression ratio, encrypted entries, duplicate entry names, unsafe - * paths, symlink entries) feeding a real, safe XML parse (XMLReader, no DTD - * loading, no network, rejecting any DOCTYPE outright) of every XML/.model/ - * .rels part — not the root relationships file alone — checking the 3MF - * model namespace/resources/build structure and rejecting any relationship - * with an external TargetMode or a Target outside the package, regardless of - * quoting style. Every XML part is read through a bounded stream (per-entry - * and whole-archive byte budgets) rather than trusting the zip's own declared - * sizes, so a size lie can't blow past the caps. + * paths, symlink entries) feeding a real, safe XML parse (DOMDocument with + * LIBXML_NONET, external entities disabled, no DTD loading, rejecting any + * DOCTYPE outright) of every XML/.model/.rels part — not the root + * relationships file alone — checking the 3MF model namespace/resources/build + * structure and rejecting any relationship with an external TargetMode or a + * Target outside the package, regardless of quoting style. Every XML part is + * read through a bounded stream (per-entry and whole-archive byte budgets) + * rather than trusting the zip's own declared sizes, so a size lie can't blow + * past the caps. DOMDocument is used instead of XMLReader because the deploy + * runner already requires `dom` and does not ship `xmlreader`. * - G-code: a plain-text check; it is never parsed or executed. * * No slicer, shell command or print job is ever invoked here, and nothing is @@ -419,35 +422,28 @@ class ManufacturingFileValidator */ private function parseRelationships(string $xml, string $context): array { - $reader = $this->openSafeXmlReader($xml, $context); + $document = $this->parseSafeXml($xml, $context, '3MF relationships part'); $targets = []; - try { - while (@$reader->read()) { - if ($reader->nodeType === XMLReader::DOC_TYPE) { - throw new RuntimeException("3MF relationships part contains a disallowed DOCTYPE declaration: {$context}."); - } - if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'Relationship') { - continue; - } - - $targetMode = $reader->getAttribute('TargetMode'); - if ($targetMode !== null && strcasecmp($targetMode, 'External') === 0) { - throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}."); - } - - $target = $reader->getAttribute('Target'); - if ($target === null || $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; + foreach ($document->getElementsByTagName('Relationship') as $relationship) { + if (! $relationship instanceof DOMElement) { + continue; } - } finally { - $this->closeSafeXmlReader($reader, $context); + + $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; @@ -455,92 +451,61 @@ class ManufacturingFileValidator private function assertGenericWellFormedXml(string $xml, string $context): void { - $reader = $this->openSafeXmlReader($xml, $context); - - try { - while (@$reader->read()) { - if ($reader->nodeType === XMLReader::DOC_TYPE) { - throw new RuntimeException("3MF XML part contains a disallowed DOCTYPE declaration: {$context}."); - } - } - } finally { - $this->closeSafeXmlReader($reader, $context); - } + $this->parseSafeXml($xml, $context, '3MF XML part'); } private function assertWellFormedModelXml(string $xml, string $context): void { - $reader = $this->openSafeXmlReader($xml, $context); - $sawRoot = false; - $sawResources = false; - $sawBuild = false; - $rootDepth = null; - - try { - while (@$reader->read()) { - if ($reader->nodeType === XMLReader::DOC_TYPE) { - throw new RuntimeException("3MF model part contains a disallowed DOCTYPE declaration: {$context}."); - } - if ($reader->nodeType !== XMLReader::ELEMENT) { - continue; - } - - if (! $sawRoot) { - if ($reader->localName !== 'model' || ! str_starts_with((string) $reader->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) { - throw new RuntimeException("3MF model part does not have a valid root element in the 3MF core namespace: {$context}."); - } - $sawRoot = true; - $rootDepth = $reader->depth; - - continue; - } - - if ($reader->depth === $rootDepth + 1) { - if ($reader->localName === 'resources') { - $sawResources = true; - } - if ($reader->localName === 'build') { - $sawBuild = true; - } - } - } - } finally { - $this->closeSafeXmlReader($reader, $context); + $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}."); } - if (! $sawRoot || ! $sawResources || ! $sawBuild) { + $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 - * encountered while reading is explicitly rejected by the callers above - * rather than relied upon to be harmless. + * 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 openSafeXmlReader(string $xml, string $context): XMLReader + 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); - $reader = XMLReader::XML($xml, null, LIBXML_NONET); - if ($reader === false) { - throw new RuntimeException("3MF part is not well-formed XML: {$context}."); - } - - return $reader; - } - - private function closeSafeXmlReader(XMLReader $reader, string $context): void - { - $errors = libxml_get_errors(); - libxml_clear_errors(); - $reader->close(); - - foreach ($errors as $error) { - if ($error->level >= LIBXML_ERR_ERROR) { - throw new RuntimeException("3MF part is not well-formed XML: {$context}."); + 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); } }