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
+69 -104
View File
@@ -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 <model> 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 <model> 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);
}
}