Files
HickLab3D_Dashboard/app/Services/ManufacturingFileValidator.php
T
hermes 52a477d56d
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 1m9s
Parse 3MF XML with DOMDocument so deploy CI can run
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.
2026-11-08 13:03:59 +00:00

576 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Models\ProjectFile;
use DOMDocument;
use DOMElement;
use Illuminate\Http\UploadedFile;
use RuntimeException;
use ZipArchive;
/**
* Structural validation for private manufacturing file uploads (STL, 3MF, G-code).
*
* MIME sniffing alone is not trustworthy for these formats, so each extension is
* combined with bounded, format-specific structural checks:
*
* - STL: a real facet/vertex grammar for ASCII (not just "starts with solid,
* contains endsolid somewhere"), a triangle-count vs file-size cross-check for
* binary with a positive triangle requirement, and a finite-float check on
* 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 (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
* ever extracted to disk.
*/
class ManufacturingFileValidator
{
public const MAX_FILE_SIZE = 100 * 1024 * 1024;
public const MAX_TOTAL_BATCH_SIZE = 250 * 1024 * 1024;
private const MAX_STL_TRIANGLES = 5_000_000;
private const MAX_3MF_ENTRIES = 2000;
private const MAX_3MF_TOTAL_UNCOMPRESSED = 128 * 1024 * 1024;
private const MAX_3MF_RATIO = 100;
private const MAX_3MF_MODEL_PART_BYTES = 64 * 1024 * 1024;
private const MAX_3MF_XML_ENTRY_BYTES = 16 * 1024 * 1024;
private const MAX_3MF_TOTAL_PARSED_BYTES = 128 * 1024 * 1024;
private const MODEL_NAMESPACE_PREFIX = 'http://schemas.microsoft.com/3dmanufacturing/core/';
private const FLOAT_PATTERN = '[-+]?(?:\d+\.\d+|\.\d+|\d+)(?:[eE][-+]?\d+)?';
/** @var array<string, string> */
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<string> $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<string, true> $seenNames */
$seenNames = [];
/** @var list<string> $modelParts */
$modelParts = [];
/** @var list<string> $relsEntries */
$relsEntries = [];
/** @var list<string> $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<string> 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 <model> 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;
}
}