P7/P4: harden recompute and FCStd boundaries

This commit is contained in:
2026-08-02 14:33:41 -04:00
parent 022a5dde2f
commit 4cc349a589
12 changed files with 848 additions and 30 deletions

243
src/facade/fcstd.ts Normal file
View File

@@ -0,0 +1,243 @@
import { unzipSync } from 'fflate'
import { XMLParser } from 'fast-xml-parser'
export type FcstdArchiveLimits = {
maxArchiveBytes: number
maxEntries: number
maxEntryBytes: number
maxTotalUncompressedBytes: number
maxCompressionRatio: number
}
export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'thumbnail' | 'script' | 'resource'
export type FcstdEntryMetadata = {
path: string
compressedBytes: number
uncompressedBytes: number
compressionMethod: number
role: FcstdEntryRole
}
export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
export type FcstdObjectSummary = {
name: string
label: string
typeId: string
propertyCount: number
support: FcstdObjectSupport
}
export type FcstdCompatibilityReport = {
level: 'metadata-compatible' | 'partial' | 'blocked'
readOnly: true
codeExecutionBlocked: true
recognizedObjects: number
proxyObjects: number
blockedObjects: number
unknownTypeIds: string[]
warnings: string[]
}
export type FcstdInspection = {
format: 'FCStd'
schemaVersion: string
label: string
entries: FcstdEntryMetadata[]
objects: FcstdObjectSummary[]
compatibility: FcstdCompatibilityReport
}
export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 20_000,
maxEntryBytes: 128 * 1024 * 1024,
maxTotalUncompressedBytes: 512 * 1024 * 1024,
maxCompressionRatio: 200,
}
const recognizedTypeIds = new Set([
'App::DocumentObjectGroup',
'App::FeaturePython',
'Part::Feature',
'Part::FeaturePython',
'PartDesign::Body',
'PartDesign::Feature',
'PartDesign::Pad',
'PartDesign::Pocket',
'PartDesign::Fillet',
'PartDesign::Chamfer',
'PartDesign::Revolution',
'Sketcher::SketchObject',
])
const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
const entryRole = (path: string): FcstdEntryRole => {
const lower = path.toLowerCase()
if (lower === 'document.xml') return 'document'
if (lower === 'guidocument.xml') return 'gui-document'
if (lower === 'thumbnails/thumbnail.png') return 'thumbnail'
if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape'
if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script'
return 'resource'
}
const validateEntryPath = (path: string) => {
if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || '<empty>'}`)
const segments = path.split('/')
if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`)
}
const findEndOfCentralDirectory = (bytes: Uint8Array) => {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const minimum = Math.max(0, bytes.byteLength - 65_557)
for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
if (view.getUint32(offset, true) === 0x06054b50) return offset
}
throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.')
}
const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => {
if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`)
if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.')
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const eocd = findEndOfCentralDirectory(bytes)
const diskNumber = view.getUint16(eocd + 4, true)
const centralDirectoryDisk = view.getUint16(eocd + 6, true)
const entriesOnDisk = view.getUint16(eocd + 8, true)
const entryCount = view.getUint16(eocd + 10, true)
const directorySize = view.getUint32(eocd + 12, true)
const directoryOffset = view.getUint32(eocd + 16, true)
const commentLength = view.getUint16(eocd + 20, true)
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.')
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.')
if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`)
if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.')
const decoder = new TextDecoder('utf-8', { fatal: true })
const entries: FcstdEntryMetadata[] = []
const paths = new Set<string>()
let cursor = directoryOffset
let totalUncompressed = 0
for (let index = 0; index < entryCount; index += 1) {
if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.')
const flags = view.getUint16(cursor + 8, true)
const compressionMethod = view.getUint16(cursor + 10, true)
const compressedBytes = view.getUint32(cursor + 20, true)
const uncompressedBytes = view.getUint32(cursor + 24, true)
const nameLength = view.getUint16(cursor + 28, true)
const extraLength = view.getUint16(cursor + 30, true)
const entryCommentLength = view.getUint16(cursor + 32, true)
const diskStart = view.getUint16(cursor + 34, true)
const next = cursor + 46 + nameLength + extraLength + entryCommentLength
if (next > eocd) throw new Error('FCStd central-directory entry is truncated.')
if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.')
if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.')
if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`)
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.')
const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength))
validateEntryPath(path)
if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`)
paths.add(path)
if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`)
const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes
if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`)
totalUncompressed += uncompressedBytes
if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`)
entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) })
cursor = next
}
if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.')
return entries
}
const asArray = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
const attribute = (node: unknown, name: string): string => {
if (!node || typeof node !== 'object') return ''
const record = node as Record<string, unknown>
const value = record[`@_${name}`] ?? record[name]
return value === undefined || value === null ? '' : String(value)
}
const propertyValue = (property: Record<string, unknown>): string => {
for (const value of Object.values(property)) {
if (!value || typeof value !== 'object') continue
const candidate = attribute(value, 'value')
if (candidate) return candidate
}
return ''
}
const parseDocumentXml = (bytes: Uint8Array) => {
const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('FCStd Document.xml declarations and entities are not allowed.')
const parsed = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
processEntities: false,
allowBooleanAttributes: false,
}).parse(xml) as Record<string, unknown>
const root = (parsed.Document ?? parsed) as Record<string, unknown>
const objectDeclarations = asArray((((root.Objects as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const objectData = asArray((((root.ObjectData as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
const documentProperties = asArray((((root.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
const name = attribute(declaration, 'name') || '<unnamed>'
const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
const data = dataByName.get(name)
const properties = asArray((((data?.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support }
})
return {
schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',
label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document',
objects,
}
}
export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdInspection => {
const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
const entries = inspectZipDirectory(bytes, limits)
const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml')
if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.')
const files = unzipSync(bytes)
for (const entry of entries) {
const content = files[entry.path]
if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`)
}
const document = parseDocumentXml(files[documentEntry.path])
const warnings: string[] = []
const scriptEntries = entries.filter((entry) => entry.role === 'script')
if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`)
const blockedObjects = document.objects.filter((object) => object.support === 'blocked')
if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`)
const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
return {
format: 'FCStd',
schemaVersion: document.schemaVersion,
label: document.label,
entries,
objects: document.objects,
compatibility: {
level,
readOnly: true,
codeExecutionBlocked: true,
recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length,
proxyObjects: proxyObjects.length,
blockedObjects: blockedObjects.length,
unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(),
warnings,
},
}
}