Parse 3MF XML with DOMDocument so deploy CI can run
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 1m9s
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:
@@ -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}.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user