Parse 3MF XML with DOMDocument so deploy CI can run
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 1m9s

The swarm runner PHP has ext-dom but not XMLReader, so the 3MF validator
crashed during Actions phpunit and blocked production rsync. DOMDocument
keeps LIBXML_NONET, disabled external entities, and DOCTYPE rejection.
This commit is contained in:
2026-11-08 13:03:59 +00:00
parent d4928789c4
commit 52a477d56d
+46 -81
View File
@@ -3,9 +3,10 @@
namespace App\Services; namespace App\Services;
use App\Models\ProjectFile; use App\Models\ProjectFile;
use DOMDocument;
use DOMElement;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use RuntimeException; use RuntimeException;
use XMLReader;
use ZipArchive; use ZipArchive;
/** /**
@@ -20,14 +21,16 @@ use ZipArchive;
* every normal/vertex component so a NaN/Infinity payload is rejected. * every normal/vertex component so a NaN/Infinity payload is rejected.
* - 3MF: a bounded zip reader (entry count, per-entry and total uncompressed * - 3MF: a bounded zip reader (entry count, per-entry and total uncompressed
* size, compression ratio, encrypted entries, duplicate entry names, unsafe * size, compression ratio, encrypted entries, duplicate entry names, unsafe
* paths, symlink entries) feeding a real, safe XML parse (XMLReader, no DTD * paths, symlink entries) feeding a real, safe XML parse (DOMDocument with
* loading, no network, rejecting any DOCTYPE outright) of every XML/.model/ * LIBXML_NONET, external entities disabled, no DTD loading, rejecting any
* .rels part — not the root relationships file alone — checking the 3MF * DOCTYPE outright) of every XML/.model/.rels part — not the root
* model namespace/resources/build structure and rejecting any relationship * relationships file alone — checking the 3MF model namespace/resources/build
* with an external TargetMode or a Target outside the package, regardless of * structure and rejecting any relationship with an external TargetMode or a
* quoting style. Every XML part is read through a bounded stream (per-entry * Target outside the package, regardless of quoting style. Every XML part is
* and whole-archive byte budgets) rather than trusting the zip's own declared * read through a bounded stream (per-entry and whole-archive byte budgets)
* sizes, so a size lie can't blow past the caps. * 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. * - 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 * No slicer, shell command or print job is ever invoked here, and nothing is
@@ -419,25 +422,21 @@ class ManufacturingFileValidator
*/ */
private function parseRelationships(string $xml, string $context): array private function parseRelationships(string $xml, string $context): array
{ {
$reader = $this->openSafeXmlReader($xml, $context); $document = $this->parseSafeXml($xml, $context, '3MF relationships part');
$targets = []; $targets = [];
try { foreach ($document->getElementsByTagName('Relationship') as $relationship) {
while (@$reader->read()) { if (! $relationship instanceof DOMElement) {
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; continue;
} }
$targetMode = $reader->getAttribute('TargetMode'); $targetMode = $relationship->getAttribute('TargetMode');
if ($targetMode !== null && strcasecmp($targetMode, 'External') === 0) { if ($targetMode !== '' && strcasecmp($targetMode, 'External') === 0) {
throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}."); throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}.");
} }
$target = $reader->getAttribute('Target'); $target = $relationship->getAttribute('Target');
if ($target === null || $target === '') { if ($target === '') {
throw new RuntimeException("3MF relationship is missing a Target attribute: {$context}."); 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, '..')) { if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $target) === 1 || str_contains($target, '..')) {
@@ -446,101 +445,67 @@ class ManufacturingFileValidator
$targets[] = $target; $targets[] = $target;
} }
} finally {
$this->closeSafeXmlReader($reader, $context);
}
return $targets; return $targets;
} }
private function assertGenericWellFormedXml(string $xml, string $context): void private function assertGenericWellFormedXml(string $xml, string $context): void
{ {
$reader = $this->openSafeXmlReader($xml, $context); $this->parseSafeXml($xml, $context, '3MF XML part');
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);
}
} }
private function assertWellFormedModelXml(string $xml, string $context): void private function assertWellFormedModelXml(string $xml, string $context): void
{ {
$reader = $this->openSafeXmlReader($xml, $context); $document = $this->parseSafeXml($xml, $context, '3MF model part');
$sawRoot = false; $root = $document->documentElement;
$sawResources = false; if ($root === null || $root->localName !== 'model' || ! str_starts_with((string) $root->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) {
$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 <model> root element in the 3MF core namespace: {$context}."); throw new RuntimeException("3MF model part does not have a valid <model> root element in the 3MF core namespace: {$context}.");
} }
$sawRoot = true;
$rootDepth = $reader->depth;
$sawResources = false;
$sawBuild = false;
foreach ($root->childNodes as $child) {
if (! $child instanceof DOMElement) {
continue; continue;
} }
if ($child->localName === 'resources') {
if ($reader->depth === $rootDepth + 1) {
if ($reader->localName === 'resources') {
$sawResources = true; $sawResources = true;
} }
if ($reader->localName === 'build') { if ($child->localName === 'build') {
$sawBuild = true; $sawBuild = true;
} }
} }
}
} finally {
$this->closeSafeXmlReader($reader, $context);
}
if (! $sawRoot || ! $sawResources || ! $sawBuild) { if (! $sawResources || ! $sawBuild) {
throw new RuntimeException("3MF model part is missing a valid model root with resources/build elements: {$context}."); 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 * No DTD loading, no network access, no entity substitution. Any DOCTYPE is
* encountered while reading is explicitly rejected by the callers above * rejected rather than relied upon to be harmless. Uses DOMDocument because
* rather than relied upon to be harmless. * `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_use_internal_errors(true);
libxml_clear_errors(); libxml_clear_errors();
libxml_set_external_entity_loader(static fn (): mixed => null);
$reader = XMLReader::XML($xml, null, LIBXML_NONET); try {
if ($reader === false) { $document = new DOMDocument;
throw new RuntimeException("3MF part is not well-formed XML: {$context}."); $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 $reader; return $document;
} } finally {
private function closeSafeXmlReader(XMLReader $reader, string $context): void
{
$errors = libxml_get_errors();
libxml_clear_errors(); libxml_clear_errors();
$reader->close(); libxml_set_external_entity_loader(null);
foreach ($errors as $error) {
if ($error->level >= LIBXML_ERR_ERROR) {
throw new RuntimeException("3MF part is not well-formed XML: {$context}.");
}
} }
} }