Harden render server and import boundaries

This commit is contained in:
th committed 2026-06-05 20:05:32 +02:00
1 parent 5cc373cf6b
commit fae55e3f13
11 files changed
+2400 -54

No files matched your search

+7 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
@@ -35,7 +35,6 @@ import SvgLayerPanel from './components/SvgLayerPanel.vue'
import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
@@ -71,6 +70,9 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const helpOpen = ref(false)
const TestOverlay = import.meta.env.DEV
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
@@ -422,7 +424,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
if (import.meta.env.DEV) testsOpen.value = true
return
}
// TAB toggles the side panels (asset sidebar + properties aside)
@@ -610,7 +612,7 @@ async function runDesktopMenuCommand(command: string) {
aboutOpen.value = true
break
case 'test-runner':
testsOpen.value = true
if (import.meta.env.DEV) testsOpen.value = true
break
}
}
@@ -803,7 +805,7 @@ onBeforeUnmount(() => {
</footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" />
<HelpDialog v-model="helpOpen" />
+10 -5
View File
@@ -2,6 +2,7 @@ import type { Scene } from '@/types/scene'
const DEFAULT_SERVER = 'http://localhost:6000'
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
const RENDER_TOKEN = import.meta.env.VITE_RENDER_TOKEN as string | undefined
export interface ServerRenderOptions {
onProgress?: (progress: number) => void
@@ -27,7 +28,10 @@ export async function renderOnServer(
const baseUrl = SERVER_URL
const startRes = await fetch(`${baseUrl}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
},
body: JSON.stringify({
scene,
options: {
@@ -42,18 +46,19 @@ export async function renderOnServer(
const body = await startRes.text().catch(() => '')
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
}
const { jobId } = (await startRes.json()) as { jobId: string }
if (!jobId) throw new Error('Server did not return a jobId')
const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
const downloadUrl = await new Promise<string>((resolve, reject) => {
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
const jobToken = encodeURIComponent(accessToken)
const es = new EventSource(`${baseUrl}/render/${jobId}/events?token=${jobToken}`)
const onAbort = () => {
try {
es.close()
} catch {
/* noop */
}
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
reject(new DOMException('aborted', 'AbortError'))
}
if (opts.signal) {
+65 -4
View File
@@ -17,6 +17,11 @@ const SIG_LOCAL = 0x04034b50
const SIG_CENTRAL = 0x02014b50
const SIG_EOCD = 0x06054b50
const VERSION_NEEDED = 20
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
const MAX_ENTRIES = 1000
const MAX_NAME_BYTES = 512
const MAX_ENTRY_BYTES = 100 * 1024 * 1024
const MAX_TOTAL_ENTRY_BYTES = 200 * 1024 * 1024
const CRC_TABLE = (() => {
const t = new Uint32Array(256)
@@ -40,12 +45,20 @@ function crc32(data: Uint8Array): number {
/** Pack a list of file entries into a flat STORE zip byte stream. */
export function zipStore(entries: ZipEntry[]): Uint8Array {
if (entries.length > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const enc = new TextEncoder()
const localChunks: Uint8Array[] = []
const centralChunks: Uint8Array[] = []
let offset = 0
for (const entry of entries) {
validateZipName(entry.name)
if (entry.data.length > MAX_ENTRY_BYTES) {
throw new Error(`ZIP entry ${entry.name} exceeds ${MAX_ENTRY_BYTES} bytes.`)
}
const nameBytes = enc.encode(entry.name)
if (nameBytes.length > MAX_NAME_BYTES) {
throw new Error(`ZIP entry name ${entry.name} is too long.`)
}
const data = entry.data
const crc = crc32(data)
// Local file header.
@@ -119,27 +132,35 @@ export function zipStore(entries: ZipEntry[]): Uint8Array {
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
* data, or compressed entries (we don't ship a deflate path). */
export function unzipStore(bytes: Uint8Array): ZipEntry[] {
if (bytes.length > MAX_ARCHIVE_BYTES) {
throw new Error(`ZIP archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`)
}
if (bytes.length < 22) throw new Error('Not a ZIP archive (too small).')
const dec = new TextDecoder()
// EOCD is at least 22 bytes and may have up to 65535 bytes of
// trailing comment; scan from the end within that window.
let eocdOff = -1
const minOff = Math.max(0, bytes.length - (22 + 65535))
for (let i = bytes.length - 22; i >= minOff; i--) {
const dv = new DataView(bytes.buffer, bytes.byteOffset + i, 4)
const dv = view(bytes, i, 4)
if (dv.getUint32(0, true) === SIG_EOCD) {
eocdOff = i
break
}
}
if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
const edv = new DataView(bytes.buffer, bytes.byteOffset + eocdOff)
const edv = view(bytes, eocdOff, 22)
const total = edv.getUint16(10, true)
if (total > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const centralSize = edv.getUint32(12, true)
const centralOff = edv.getUint32(16, true)
ensureRange(bytes, centralOff, centralSize, 'ZIP central directory')
const entries: ZipEntry[] = []
let p = centralOff
let extractedTotal = 0
for (let i = 0; i < total; i++) {
const cdv = new DataView(bytes.buffer, bytes.byteOffset + p)
const cdv = view(bytes, p, 46)
if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
throw new Error('Bad central directory entry.')
}
@@ -149,20 +170,30 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
const extraLen = cdv.getUint16(30, true)
const commentLen = cdv.getUint16(32, true)
const localOff = cdv.getUint32(42, true)
if (nameLen > MAX_NAME_BYTES) throw new Error('ZIP entry name is too long.')
if (size > MAX_ENTRY_BYTES) throw new Error(`ZIP entry exceeds ${MAX_ENTRY_BYTES} bytes.`)
extractedTotal += size
if (extractedTotal > MAX_TOTAL_ENTRY_BYTES) {
throw new Error(`ZIP extracted data exceeds ${MAX_TOTAL_ENTRY_BYTES} bytes.`)
}
if (method !== 0) {
throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
}
ensureRange(bytes, p, 46 + nameLen + extraLen + commentLen, 'ZIP central directory entry')
const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
const name = dec.decode(nameBytes)
validateZipName(name)
// Re-read the local header to skip its variable-length fields
// before the file data.
const ldv = new DataView(bytes.buffer, bytes.byteOffset + localOff)
const ldv = view(bytes, localOff, 30)
if (ldv.getUint32(0, true) !== SIG_LOCAL) {
throw new Error('Bad local file header.')
}
const lNameLen = ldv.getUint16(26, true)
const lExtraLen = ldv.getUint16(28, true)
const dataOff = localOff + 30 + lNameLen + lExtraLen
ensureRange(bytes, localOff, 30 + lNameLen + lExtraLen, 'ZIP local file header')
ensureRange(bytes, dataOff, size, 'ZIP entry data')
const data = bytes.subarray(dataOff, dataOff + size)
// Copy so callers get a standalone Uint8Array they can keep.
entries.push({ name, data: new Uint8Array(data) })
@@ -171,6 +202,36 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
return entries
}
function view(bytes: Uint8Array, offset: number, length: number): DataView {
ensureRange(bytes, offset, length, 'ZIP data')
return new DataView(bytes.buffer, bytes.byteOffset + offset, length)
}
function ensureRange(bytes: Uint8Array, offset: number, length: number, label: string): void {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > bytes.length ||
offset + length > bytes.length
) {
throw new Error(`${label} is outside the archive.`)
}
}
function validateZipName(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.startsWith('\\') ||
name.includes('\\') ||
name.split('/').some((part) => part === '..' || part === '')
) {
throw new Error(`Unsafe ZIP entry name: ${name || '(empty)'}.`)
}
}
/** Magic check — `true` when the first four bytes match the ZIP local
* file header signature (PK\x03\x04). Used by the project-file
* reader to decide between .ruw (zip) and .json (legacy). */