Compare commits
3
Commits
5cc373cf6b
...
46f9f32aef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f9f32aef | ||
|
|
b536e8cf73 | ||
|
|
fae55e3f13 |
No files matched your search
@@ -68,6 +68,34 @@ npm run desktop:build # build platform installer/package artifacts into release
|
|||||||
app without creating an installer. `desktop:build` uses `electron-builder` and
|
app without creating an installer. `desktop:build` uses `electron-builder` and
|
||||||
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
|
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
|
||||||
|
|
||||||
|
## MP4 render server
|
||||||
|
|
||||||
|
MP4 export is handled by the optional render server in `server/`. It binds to
|
||||||
|
`127.0.0.1:6000` by default and requires a token before it will accept render
|
||||||
|
jobs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm ci
|
||||||
|
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the app at the server with matching Vite env values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes `HOST=0.0.0.0` only when explicitly requested. If you expose
|
||||||
|
it beyond localhost, set `RENDER_ALLOWED_ORIGINS` to the exact app origins that
|
||||||
|
should be allowed. Chromium sandboxing stays enabled by default; use
|
||||||
|
`CHROME_NO_SANDBOX=1` only for a deployment environment that cannot run the
|
||||||
|
sandbox.
|
||||||
|
|
||||||
|
The included `docker-compose.yml` keeps the published host port on
|
||||||
|
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
|
||||||
|
start.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Vue 3 (`<script setup>`) + Pinia state
|
- Vue 3 (`<script setup>`) + Pinia state
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Security Remediation Plan
|
||||||
|
|
||||||
|
Date: 2026-06-05
|
||||||
|
|
||||||
|
Source review: `dev_docs/security-review.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Close the security issues from the review without changing RUWIO's core authoring workflow. The first priority is to make the MP4 render server safe to run on a developer machine by default; the second is to make project and bundle input handling bounded and reproducible.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Secure defaults first: local-only render server, explicit opt-in for network exposure, and bounded work.
|
||||||
|
- Validate at every trust boundary: imported files, `.ruw` bundles, render requests, Electron IPC, and generated/exported HTML.
|
||||||
|
- Keep browser and desktop builds working while hardening server-only paths.
|
||||||
|
- Add tests for security behavior so fixes do not drift quietly.
|
||||||
|
|
||||||
|
## Phase 0: Baseline And Guardrails
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `dev_docs/security-review.md`
|
||||||
|
- `package.json`
|
||||||
|
- `server/package.json`
|
||||||
|
- `server/server.js`
|
||||||
|
- `src/devtools/tests.ts`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Capture a clean baseline with `npm run build`.
|
||||||
|
2. Add or document a lightweight verification command for render-server checks once server tests exist.
|
||||||
|
3. Decide the initial production limits so later validation has concrete numbers:
|
||||||
|
- maximum canvas width and height
|
||||||
|
- maximum render fps
|
||||||
|
- maximum render duration and total frames
|
||||||
|
- maximum layer, clip, keyframe, scene, asset, and point counts
|
||||||
|
- maximum JSON body, `.json` project, `.ruw` bundle, per-ZIP-entry, and total extracted asset size
|
||||||
|
4. Record the selected limits in this document or a small constants module.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- Existing build still passes before remediation starts.
|
||||||
|
- Limits are explicit instead of scattered magic numbers.
|
||||||
|
|
||||||
|
## Phase 1: Lock Down The Render Server
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- High: unauthenticated cross-origin render server
|
||||||
|
- Part of Medium: unbounded render requests
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `server/server.js`
|
||||||
|
- `src/lib/serverExport.ts`
|
||||||
|
- `src/components/ExportDialog.vue`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Bind the server to `127.0.0.1` by default:
|
||||||
|
- Add `HOST=127.0.0.1` default in `server/server.js`.
|
||||||
|
- Use `app.listen(PORT, HOST, ...)`.
|
||||||
|
- Change Docker Compose publishing from `6000:6000` to `127.0.0.1:6000:6000`.
|
||||||
|
- Require an explicit `HOST=0.0.0.0` or compose override for LAN exposure.
|
||||||
|
2. Replace default `cors()` with an allowlist:
|
||||||
|
- Add `RENDER_ALLOWED_ORIGINS`, defaulting to the Vite dev origin and localhost app origins that are actually needed.
|
||||||
|
- Reject browser requests with unexpected `Origin` values.
|
||||||
|
- Keep non-browser/local CLI requests possible when they have no `Origin` and pass auth.
|
||||||
|
3. Add authentication:
|
||||||
|
- Add `RENDER_TOKEN`.
|
||||||
|
- Require `Authorization: Bearer <token>` or `X-RUWIO-Render-Token` for `POST /render`.
|
||||||
|
- Because `EventSource` cannot send custom headers, have `POST /render` return a random per-job access token, then require that token for `/render/:id/events`, `/render/:id/file`, and `DELETE /render/:id`.
|
||||||
|
- Add render-server settings in the client if the token must be user-configurable.
|
||||||
|
4. Add basic abuse controls:
|
||||||
|
- Lower `express.json` from `200mb` to a documented default, with an environment override.
|
||||||
|
- Reject requests with a too-large `Content-Length` before JSON parsing.
|
||||||
|
- Add per-IP request rate limits for `/render`.
|
||||||
|
- Add `MAX_ACTIVE_JOBS` and `MAX_QUEUED_JOBS`; return `429` or `503` when full.
|
||||||
|
- Add a per-job timeout that aborts Puppeteer and `ffmpeg`.
|
||||||
|
5. Improve job cleanup:
|
||||||
|
- Delete failed and aborted job directories.
|
||||||
|
- Remove completed job metadata after download TTL expires.
|
||||||
|
- Ensure `ffmpeg` is killed when Chromium/page work fails.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- A default server only listens on localhost.
|
||||||
|
- Cross-origin requests from an unlisted origin fail.
|
||||||
|
- Requests without the configured token fail.
|
||||||
|
- A completed job can still be started, watched, downloaded, and deleted by the RUWIO client.
|
||||||
|
- Concurrent jobs, over-large bodies, and over-long renders are rejected with clear errors.
|
||||||
|
|
||||||
|
## Phase 2: Harden Chromium And The Container
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- High: Chromium renders untrusted data with sandbox disabled
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `server/server.js`
|
||||||
|
- `server/Dockerfile`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Run the container as a non-root user:
|
||||||
|
- Create an app user/group in `server/Dockerfile`.
|
||||||
|
- Ensure `/app` and temporary render directories are writable by that user.
|
||||||
|
- Set `USER` before `CMD`.
|
||||||
|
2. Remove unconditional sandbox-disabling Chromium flags:
|
||||||
|
- Try launching Chromium without `--no-sandbox` and `--disable-setuid-sandbox`.
|
||||||
|
- Keep an explicit `CHROME_NO_SANDBOX=1` escape hatch only if needed for a known deployment environment.
|
||||||
|
- Log a loud warning when the escape hatch is used.
|
||||||
|
3. Add process and container limits:
|
||||||
|
- Configure Docker Compose memory/CPU/pids limits where supported.
|
||||||
|
- Add Node-side child-process kill paths for abort, timeout, and client disconnect.
|
||||||
|
- Consider using a dedicated worker module/process so Express request handling and rendering are isolated.
|
||||||
|
4. Limit network reachability from render jobs:
|
||||||
|
- Prefer rendering local/data assets only.
|
||||||
|
- If external URLs must remain supported, document the SSRF/privacy tradeoff and add an allowlist or opt-in later.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- The Docker image runs as a non-root user.
|
||||||
|
- The normal render path works without `--no-sandbox`, or the documented fallback is explicit and visible.
|
||||||
|
- Aborted/timed-out jobs leave no running Chromium or `ffmpeg` process.
|
||||||
|
|
||||||
|
## Phase 3: Add Shared Project Validation And Resource Bounds
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- Medium: project and bundle imports lack deep validation and bounds
|
||||||
|
- Part of High/Medium: unbounded render workload
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `src/types/scene.ts`
|
||||||
|
- new `src/lib/projectLimits.ts`
|
||||||
|
- new `src/lib/projectValidation.ts`
|
||||||
|
- `src/lib/projectFile.ts`
|
||||||
|
- `src/lib/ruwFile.ts`
|
||||||
|
- `src/lib/zipUtil.ts`
|
||||||
|
- `server/server.js`
|
||||||
|
- possibly `server/validation.js` or a shared package if server reuse becomes necessary
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Add centralized limits:
|
||||||
|
- Keep numeric bounds in one module.
|
||||||
|
- Use browser-safe limits for imports and stricter server-safe limits for rendering if needed.
|
||||||
|
2. Add schema validation at import boundaries:
|
||||||
|
- Validate `Project`, `Scene`, `Asset`, `Layer`, `Keyframe`, clips, actions, background media, and canvas data.
|
||||||
|
- Reject non-finite numbers, negative dimensions, invalid colors where practical, oversized strings, and unknown critical shapes.
|
||||||
|
- Normalize optional arrays and legacy fields into safe defaults.
|
||||||
|
- Return friendly `ProjectFileError` messages rather than raw parser errors.
|
||||||
|
3. Add render-request validation:
|
||||||
|
- Validate `scene` and `options` before creating a job.
|
||||||
|
- Clamp or reject `fps`, `duration`, `crf`, `preset`, canvas size, and total frame count.
|
||||||
|
- Compute `totalFrames` only after validation.
|
||||||
|
4. Harden ZIP parsing:
|
||||||
|
- Add caps for entry count, archive byte length, central directory range, name length, per-entry size, and total extracted size.
|
||||||
|
- Check every local/central directory offset and data range before creating `DataView` or `subarray`.
|
||||||
|
- Reject path traversal and absolute paths even though extraction is in-memory.
|
||||||
|
- Preserve STORE-only behavior.
|
||||||
|
5. Add import tests:
|
||||||
|
- Valid legacy `.json` and `.ruw` still load.
|
||||||
|
- Malformed JSON reports a friendly error.
|
||||||
|
- Oversized canvas, extreme fps/duration/keyframes/assets, malformed ZIP offsets, too many entries, and missing assets are rejected.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- Project import returns a safe internal `Project` shape.
|
||||||
|
- `.ruw` parsing fails early for oversized or malformed archives.
|
||||||
|
- Render server rejects pathological render requests before launching Chromium.
|
||||||
|
- Existing normal projects still open and export.
|
||||||
|
|
||||||
|
## Phase 4: Make Server Dependencies Reproducible
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- Medium: render server dependency installs are not reproducible
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `server/package-lock.json`
|
||||||
|
- `server/Dockerfile`
|
||||||
|
- root CI config when one exists
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Generate and commit `server/package-lock.json`.
|
||||||
|
2. Replace `npm install --omit=dev` with `npm ci --omit=dev` in `server/Dockerfile`.
|
||||||
|
3. Add server audit/documentation:
|
||||||
|
- `npm audit --prefix server --omit=dev`
|
||||||
|
- Add the command to local release checks or CI.
|
||||||
|
4. Rebuild the server image from a clean checkout to verify the lockfile path.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- `npm audit --prefix server --omit=dev` can run.
|
||||||
|
- Docker builds use the committed lockfile.
|
||||||
|
- Repeated builds install identical dependency versions.
|
||||||
|
|
||||||
|
## Phase 5: Remove Devtools From Production Bundles
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- Medium: development test overlay ships in production
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `src/App.vue`
|
||||||
|
- `src/devtools/TestOverlay.vue`
|
||||||
|
- `src/devtools/tests.ts`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Replace the static `TestOverlay` import with a development-only dynamic import:
|
||||||
|
- Use `defineAsyncComponent` only when `import.meta.env.DEV` is true.
|
||||||
|
- Mount the overlay only in development.
|
||||||
|
2. Guard menu/command paths that open the test runner:
|
||||||
|
- Keep Electron menu gating already present.
|
||||||
|
- Ensure renderer-side command handling ignores `test-runner` in production.
|
||||||
|
3. Verify bundle output:
|
||||||
|
- Run `npm run build`.
|
||||||
|
- Confirm production chunks do not include `src/devtools/tests.ts` or the test overlay.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- Production build output excludes devtools tests.
|
||||||
|
- Development build still opens the test overlay.
|
||||||
|
|
||||||
|
## Phase 6: Tighten Electron Navigation And External Links
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- Low: Electron navigation and external link policy could be stricter
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `electron/main.cts`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Replace string-prefix URL checks with parsed `URL` checks.
|
||||||
|
2. Define allowed renderer origins:
|
||||||
|
- Development: exactly the configured Vite origin.
|
||||||
|
- Production: the packaged `file://` app entry and same-directory app assets.
|
||||||
|
3. Restrict external opening:
|
||||||
|
- Prefer `https:` only.
|
||||||
|
- Add an explicit allowlist for help/about domains if external links are needed.
|
||||||
|
- Deny `file:`, custom protocols, `javascript:`, and unexpected `http:`.
|
||||||
|
4. Add small unit or smoke tests if Electron test infrastructure exists; otherwise document manual packaged-app checks.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- App navigation works in dev and packaged builds.
|
||||||
|
- Unexpected app navigation is prevented.
|
||||||
|
- External links are opened only when they match the policy.
|
||||||
|
|
||||||
|
## Phase 7: Add Content Security Policy
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
|
||||||
|
- Low: no Content Security Policy is defined
|
||||||
|
|
||||||
|
Files likely touched:
|
||||||
|
|
||||||
|
- `index.html`
|
||||||
|
- possibly Vite config if separate dev/prod CSP handling is needed
|
||||||
|
- generated export code only if app-shell CSP breaks previews
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
1. Add an app-shell CSP meta tag.
|
||||||
|
2. Start with a policy that supports current app behavior:
|
||||||
|
- scripts from self
|
||||||
|
- images/media/fonts from self plus `data:`/`blob:` where required for user assets
|
||||||
|
- connections to self plus configured render server during development/export
|
||||||
|
- no object/embed
|
||||||
|
3. Verify the Electron renderer and browser app do not log CSP violations for normal workflows.
|
||||||
|
4. Keep generated HTML exports separate if they need a looser asset policy; do not silently weaken the app shell for exports.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- App loads in browser dev, production preview, and Electron.
|
||||||
|
- Common import/export/preview workflows work without unexpected CSP violations.
|
||||||
|
- CSP blocks inline or remote script execution in the app shell.
|
||||||
|
|
||||||
|
## Suggested Pull Request Order
|
||||||
|
|
||||||
|
1. Render server exposure and authentication.
|
||||||
|
2. Render server job limits, body limits, and cleanup.
|
||||||
|
3. Container non-root user and Chromium sandbox work.
|
||||||
|
4. Shared limits plus render-request validation.
|
||||||
|
5. Project and `.ruw` import validation.
|
||||||
|
6. Server lockfile and Docker `npm ci`.
|
||||||
|
7. Production devtools gating.
|
||||||
|
8. Electron URL policy.
|
||||||
|
9. CSP.
|
||||||
|
|
||||||
|
## Final Verification Checklist
|
||||||
|
|
||||||
|
- `npm run build`
|
||||||
|
- `npm audit --omit=dev`
|
||||||
|
- `npm audit --prefix server --omit=dev`
|
||||||
|
- Render server rejects unauthenticated `/render`.
|
||||||
|
- Render server rejects disallowed `Origin`.
|
||||||
|
- Render server rejects oversized request bodies and excessive frame counts.
|
||||||
|
- Normal MP4 render still completes through the app.
|
||||||
|
- Malformed `.json` and `.ruw` files fail with friendly errors.
|
||||||
|
- Production bundle does not contain `src/devtools/tests.ts`.
|
||||||
|
- Packaged Electron app opens only expected app URLs and approved external links.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Security Review
|
||||||
|
|
||||||
|
Date: 2026-06-05
|
||||||
|
|
||||||
|
Scope reviewed:
|
||||||
|
|
||||||
|
- Vue/Vite renderer in `src/`
|
||||||
|
- Electron main/preload in `electron/`
|
||||||
|
- MP4 render server in `server/`
|
||||||
|
- Project import/export paths for `.json`, `.ruw`, backups, assets, fonts, and generated HTML exports
|
||||||
|
- Build/dependency posture at the repository root
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The desktop shell starts from a good Electron baseline: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, and a narrow preload bridge. I did not find direct Node exposure to the Vue renderer.
|
||||||
|
|
||||||
|
The highest-risk area is the standalone MP4 render server. It accepts unauthenticated cross-origin render jobs, large request bodies, and attacker-controlled scene data, then renders it in Chromium with `--no-sandbox` and feeds frames to `ffmpeg`. That combination should be treated as a network-exposed compute/sandbox boundary until it is tightened.
|
||||||
|
|
||||||
|
The second broad theme is input hardening. Project files and `.ruw` bundles are structurally checked, but not deeply schema-validated or bounded before the app uses canvas sizes, frame counts, assets, and timelines. A crafted file can likely cause heavy CPU/memory use in both browser and server render paths.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### High: Render Server Is Unauthenticated And Cross-Origin
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `server/server.js:19` enables default `cors()`, allowing browser requests from any origin.
|
||||||
|
- `server/server.js:20` accepts JSON bodies up to `200mb`.
|
||||||
|
- `server/server.js:27` starts render jobs without authentication, CSRF protection, origin checks, rate limits, or concurrency limits.
|
||||||
|
- `docker-compose.yml:4-5` publishes port `6000` on the host.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
Any site reachable by a user's browser, or any network client that can reach the service, can submit expensive render jobs. This can exhaust CPU, memory, disk, Chromium processes, or `ffmpeg` work queues. If the server is ever exposed beyond localhost, it becomes a public unauthenticated render farm.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Bind the service to localhost by default, or require an explicit opt-in for network exposure.
|
||||||
|
- Replace default `cors()` with an allowlist for the app origin.
|
||||||
|
- Add authentication or a per-session token for `/render`, `/events`, `/file`, and `DELETE`.
|
||||||
|
- Add request rate limits, maximum active jobs, queue limits, and per-job timeouts.
|
||||||
|
- Lower the default body limit and reject requests before parsing when possible.
|
||||||
|
|
||||||
|
### High: Chromium Renders Untrusted Data With Sandbox Disabled
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `server/server.js:126-134` launches Chromium with `--no-sandbox` and `--disable-setuid-sandbox`.
|
||||||
|
- `server/server.js:166-167` loads `render.html` and injects caller-provided scene data into the page.
|
||||||
|
- `server/Dockerfile:1-20` uses the default root user; no non-root `USER` is configured.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
The server renders attacker-controlled image/audio/video/SVG-like asset data in Chromium. If a browser engine, image decoder, font decoder, media decoder, or canvas path is exploited, disabling the Chromium sandbox and running the container as root increases the blast radius.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Run the container as a non-root user.
|
||||||
|
- Prefer enabling the Chromium sandbox. If the container needs extra setup, document and configure it rather than relying on `--no-sandbox`.
|
||||||
|
- Treat each render job as untrusted: use process-level timeouts, memory/CPU limits, and container hardening.
|
||||||
|
- Consider a separate locked-down worker container for renders.
|
||||||
|
|
||||||
|
### Medium: Project And Bundle Imports Lack Deep Validation And Bounds
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `src/lib/projectFile.ts:39-67` checks only top-level `magic`, `canvas`, `scenes`, and `assets` shape before returning the imported object as `Project`.
|
||||||
|
- `src/lib/zipUtil.ts:121-171` parses ZIP entries into memory without explicit caps on entry count, central directory size, total extracted size, or path/name length.
|
||||||
|
- `server/server.js:115-119` derives `fps`, `duration`, and `totalFrames` directly from request data.
|
||||||
|
- `server/server.js:135-138` uses request-provided canvas dimensions as the Chromium viewport.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
A crafted `.json`, `.ruw`, backup, or server request can specify extreme canvas sizes, frame counts, layer/keyframe arrays, base64 assets, font data, or ZIP metadata. The likely outcome is denial of service: browser tab hangs, Electron renderer memory pressure, server CPU exhaustion, or failed/very slow exports.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Add a real project schema validator at file and server boundaries.
|
||||||
|
- Clamp canvas dimensions, duration, fps, layer counts, keyframe counts, asset count, asset byte size, and total project size.
|
||||||
|
- Add ZIP reader bounds and clearer errors for malformed/truncated archives.
|
||||||
|
- Normalize imported projects into a safe internal shape instead of trusting arbitrary object fields.
|
||||||
|
|
||||||
|
### Medium: Render Server Dependency Installs Are Not Reproducible
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `server/package.json` exists, but no `server/package-lock.json` is present.
|
||||||
|
- `server/Dockerfile:15-16` copies only `package.json` and runs `npm install --omit=dev`.
|
||||||
|
- `npm audit --prefix server --omit=dev` cannot run without a lockfile.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
Server builds may pull different transitive dependency versions over time. That makes vulnerability tracking, incident response, and reproducible releases harder.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Commit `server/package-lock.json`.
|
||||||
|
- Use `npm ci --omit=dev` in the server Dockerfile.
|
||||||
|
- Add server dependency auditing to CI.
|
||||||
|
|
||||||
|
### Medium: Development Test Overlay Ships In The Production Bundle
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `src/App.vue:38` statically imports `./devtools/TestOverlay.vue`.
|
||||||
|
- `src/App.vue:806` always mounts the component.
|
||||||
|
- `npm run build` output shows `src/devtools/tests.ts` pulled into the production bundle through static/dynamic import relationships.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
This increases production attack surface and bundle size. Even if the test runner is intended to open only from a development-only Electron menu item, the code is still shipped to every web and desktop user.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Gate the test overlay behind `import.meta.env.DEV`.
|
||||||
|
- Load it with `defineAsyncComponent` or dynamic import only in development.
|
||||||
|
- Ensure production builds do not include `src/devtools/tests.ts`.
|
||||||
|
|
||||||
|
### Low: Electron Navigation And External Link Policy Could Be Stricter
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `electron/main.cts:40-44` opens any `http://` or `https://` URL externally.
|
||||||
|
- `electron/main.cts:47-51` uses string `startsWith` checks for navigation allow/deny decisions.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
This is not a direct Node compromise because the renderer is isolated. Still, if untrusted content ever reaches a clickable/openable path, arbitrary external links can be launched. String-prefix navigation checks are also easier to get wrong than parsed URL origin/path checks.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Prefer parsed URL checks over `startsWith`.
|
||||||
|
- Allow only expected origins in development.
|
||||||
|
- Consider allowing only `https:` external links, or an explicit domain allowlist for help/about links.
|
||||||
|
|
||||||
|
### Low: No Content Security Policy Is Defined
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `index.html` and built `dist/index.html` have no CSP meta tag.
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
|
||||||
|
Vue's default escaping reduces XSS risk, and Electron disables Node integration. A CSP would still provide defense in depth for the web app, generated asset previews, and Electron renderer by limiting script, media, image, and connection destinations.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Add a CSP for the app shell.
|
||||||
|
- Account for required `data:`/`blob:` sources for user assets and exports.
|
||||||
|
- Add Electron-specific CSP validation during packaged smoke tests.
|
||||||
|
|
||||||
|
## Positive Notes
|
||||||
|
|
||||||
|
- Electron uses `contextIsolation`, disables `nodeIntegration`, enables renderer sandboxing, and exposes only a small preload API.
|
||||||
|
- The preload bridge does not expose arbitrary IPC invocation or Node primitives.
|
||||||
|
- The Electron app denies `window.open` in-app and prevents most unexpected navigation.
|
||||||
|
- `.ruw` uses STORE-only ZIP handling, avoiding decompression bombs from deflated entries.
|
||||||
|
- The root production dependency audit completed with `0 vulnerabilities`.
|
||||||
|
- A shallow secret scan found no obvious committed `.env`, key, secret, PEM, or certificate files outside dependencies.
|
||||||
|
|
||||||
|
## Verification Performed
|
||||||
|
|
||||||
|
- `npm run build` passed.
|
||||||
|
- `npm audit --omit=dev` passed with `0 vulnerabilities`.
|
||||||
|
- `npm audit --prefix server --omit=dev` could not run because `server/` has no lockfile.
|
||||||
|
|
||||||
|
## Suggested Remediation Order
|
||||||
|
|
||||||
|
1. Lock down the render server: localhost binding, CORS allowlist, auth/token, rate limits, job limits, and timeouts.
|
||||||
|
2. Run render workers as non-root and remove Chromium `--no-sandbox` if feasible.
|
||||||
|
3. Add schema validation and resource bounds for imported projects, `.ruw` bundles, backups, and render requests.
|
||||||
|
4. Add `server/package-lock.json`, switch Docker to `npm ci --omit=dev`, and audit server dependencies in CI.
|
||||||
|
5. Remove devtools/test runner code from production bundles.
|
||||||
|
6. Add CSP and stricter Electron URL parsing/allowlists.
|
||||||
+6
-1
@@ -2,9 +2,14 @@ services:
|
|||||||
animatr-render:
|
animatr-render:
|
||||||
build: ./server
|
build: ./server
|
||||||
ports:
|
ports:
|
||||||
- "6000:6000"
|
- "127.0.0.1:6000:6000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=6000
|
- PORT=6000
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- RENDER_TOKEN=${RENDER_TOKEN:?set RENDER_TOKEN before starting the render server}
|
||||||
shm_size: "2gb"
|
shm_size: "2gb"
|
||||||
|
pids_limit: 256
|
||||||
|
mem_limit: 2g
|
||||||
|
cpus: 2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
+26
-3
@@ -1,9 +1,11 @@
|
|||||||
import { app, BrowserWindow, Menu, shell } from 'electron'
|
import { app, BrowserWindow, Menu, shell } from 'electron'
|
||||||
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
|
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
|
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
|
||||||
|
const devServerOrigin = new URL(devServerUrl).origin
|
||||||
const appIconPath = isDev
|
const appIconPath = isDev
|
||||||
? path.join(process.cwd(), 'build', 'icon.png')
|
? path.join(process.cwd(), 'build', 'icon.png')
|
||||||
: path.join(process.resourcesPath, 'build', 'icon.png')
|
: path.join(process.resourcesPath, 'build', 'icon.png')
|
||||||
@@ -15,6 +17,28 @@ function sendAppCommand(command: string): void {
|
|||||||
mainWindow.webContents.send('ruwio:menu-command', command)
|
mainWindow.webContents.send('ruwio:menu-command', command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUrl(raw: string): URL | null {
|
||||||
|
try {
|
||||||
|
return new URL(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedAppNavigation(raw: string): boolean {
|
||||||
|
const url = parseUrl(raw)
|
||||||
|
if (!url) return false
|
||||||
|
if (isDev) return url.origin === devServerOrigin
|
||||||
|
if (url.protocol !== 'file:') return false
|
||||||
|
const appEntry = path.join(app.getAppPath(), 'dist', 'index.html')
|
||||||
|
return path.normalize(fileURLToPath(url)) === path.normalize(appEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedExternalUrl(raw: string): boolean {
|
||||||
|
const url = parseUrl(raw)
|
||||||
|
return url?.protocol === 'https:'
|
||||||
|
}
|
||||||
|
|
||||||
function createMainWindow(): void {
|
function createMainWindow(): void {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
@@ -38,15 +62,14 @@ function createMainWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
|
||||||
if (url.startsWith('https://') || url.startsWith('http://')) {
|
if (isAllowedExternalUrl(url)) {
|
||||||
void shell.openExternal(url)
|
void shell.openExternal(url)
|
||||||
}
|
}
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
|
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
|
||||||
const allowedUrl = isDev ? devServerUrl : `file://${path.join(app.getAppPath(), 'dist', 'index.html')}`
|
if (!isAllowedAppNavigation(url)) {
|
||||||
if (!url.startsWith(allowedUrl)) {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#17171b" />
|
<meta name="theme-color" content="#17171b" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
/>
|
||||||
<title>RUWIO</title>
|
<title>RUWIO</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.14",
|
"version": "1.2.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-electron/main.cjs",
|
"main": "dist-electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+7
-2
@@ -10,11 +10,16 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENV PORT=6000
|
ENV PORT=6000
|
||||||
|
ENV HOST=127.0.0.1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN groupadd -r ruwio && useradd -r -g ruwio -d /app -s /usr/sbin/nologin ruwio \
|
||||||
|
&& chown -R ruwio:ruwio /app
|
||||||
|
USER ruwio
|
||||||
|
|
||||||
EXPOSE 6000
|
EXPOSE 6000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
Generated
+2007
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,6 @@
|
|||||||
"dev": "node server.js"
|
"dev": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"puppeteer-core": "^23.11.1"
|
"puppeteer-core": "^23.11.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-33
@@ -1,9 +1,8 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import puppeteer from 'puppeteer-core'
|
import puppeteer from 'puppeteer-core'
|
||||||
@@ -11,43 +10,77 @@ import puppeteer from 'puppeteer-core'
|
|||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 6000)
|
const HOST = process.env.HOST || '127.0.0.1'
|
||||||
|
const PORT = readIntEnv('PORT', 6000, 1, 65535)
|
||||||
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
|
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
|
||||||
const RENDER_HTML = path.join(__dirname, 'render.html')
|
const RENDER_HTML = path.join(__dirname, 'render.html')
|
||||||
|
const RENDER_TOKEN = process.env.RENDER_TOKEN || ''
|
||||||
|
const ALLOWED_ORIGINS = new Set(
|
||||||
|
(process.env.RENDER_ALLOWED_ORIGINS ||
|
||||||
|
'http://localhost:5200,http://127.0.0.1:5200,http://localhost:5173,http://127.0.0.1:5173,http://localhost:6000,http://127.0.0.1:6000')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const BODY_LIMIT_BYTES = readSizeEnv('RENDER_MAX_BODY', 15 * 1024 * 1024)
|
||||||
|
const MAX_ACTIVE_JOBS = readIntEnv('MAX_ACTIVE_JOBS', 1, 1, 16)
|
||||||
|
const JOB_TIMEOUT_MS = readIntEnv('RENDER_JOB_TIMEOUT_MS', 2 * 60 * 1000, 1000, 60 * 60 * 1000)
|
||||||
|
const JOB_TTL_MS = readIntEnv('RENDER_JOB_TTL_MS', 10 * 60 * 1000, 1000, 24 * 60 * 60 * 1000)
|
||||||
|
const RATE_LIMIT_WINDOW_MS = readIntEnv('RENDER_RATE_WINDOW_MS', 60 * 1000, 1000, 60 * 60 * 1000)
|
||||||
|
const RATE_LIMIT_MAX = readIntEnv('RENDER_RATE_MAX', 8, 1, 1000)
|
||||||
|
const MAX_CANVAS_WIDTH = readIntEnv('RENDER_MAX_CANVAS_WIDTH', 4096, 1, 16384)
|
||||||
|
const MAX_CANVAS_HEIGHT = readIntEnv('RENDER_MAX_CANVAS_HEIGHT', 4096, 1, 16384)
|
||||||
|
const MAX_FPS = readIntEnv('RENDER_MAX_FPS', 60, 1, 240)
|
||||||
|
const MAX_DURATION = readIntEnv('RENDER_MAX_DURATION', 60, 1, 60 * 60)
|
||||||
|
const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000)
|
||||||
|
const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
|
||||||
|
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
|
||||||
|
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(cors())
|
app.disable('x-powered-by')
|
||||||
app.use(express.json({ limit: '200mb' }))
|
app.use(originGuard)
|
||||||
|
app.use(rejectLargeBodies)
|
||||||
|
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
|
||||||
|
|
||||||
/** @type {Map<string, Job>} */
|
/** @type {Map<string, Job>} */
|
||||||
const jobs = new Map()
|
const jobs = new Map()
|
||||||
|
const rateBuckets = new Map()
|
||||||
|
|
||||||
app.get('/health', (_req, res) => res.json({ ok: true }))
|
app.get('/health', (_req, res) => res.json({ ok: true }))
|
||||||
|
|
||||||
app.post('/render', (req, res) => {
|
app.post('/render', requireRenderToken, rateLimitRender, (req, res) => {
|
||||||
|
if (activeJobCount() >= MAX_ACTIVE_JOBS) {
|
||||||
|
return res.status(503).json({ error: 'render server is busy' })
|
||||||
|
}
|
||||||
const { scene, options } = req.body || {}
|
const { scene, options } = req.body || {}
|
||||||
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
|
const validation = validateRenderRequest(scene, options || {})
|
||||||
return res.status(400).json({ error: 'invalid scene' })
|
if (!validation.ok) {
|
||||||
|
return res.status(400).json({ error: validation.error })
|
||||||
}
|
}
|
||||||
const job = {
|
const job = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
accessToken: randomToken(),
|
||||||
progress: 0,
|
progress: 0,
|
||||||
done: false,
|
done: false,
|
||||||
error: null,
|
error: null,
|
||||||
filePath: null,
|
filePath: null,
|
||||||
|
workDir: null,
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
abort: new AbortController(),
|
abort: new AbortController(),
|
||||||
|
cleanupTimer: null,
|
||||||
}
|
}
|
||||||
jobs.set(job.id, job)
|
jobs.set(job.id, job)
|
||||||
res.json({ jobId: job.id })
|
res.json({ jobId: job.id, accessToken: job.accessToken })
|
||||||
runJob(job, scene, options || {}).catch((err) => {
|
runJob(job, validation.scene, validation.options).catch((err) => {
|
||||||
job.error = err?.message || String(err)
|
job.error = err?.message || String(err)
|
||||||
job.done = true
|
job.done = true
|
||||||
notify(job)
|
notify(job)
|
||||||
|
scheduleCleanup(job, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/render/:id/events', (req, res) => {
|
app.get('/render/:id/events', requireJobToken, (req, res) => {
|
||||||
const job = jobs.get(req.params.id)
|
const job = jobs.get(req.params.id)
|
||||||
if (!job) return res.status(404).end()
|
if (!job) return res.status(404).end()
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -60,7 +93,7 @@ app.get('/render/:id/events', (req, res) => {
|
|||||||
progress: job.progress,
|
progress: job.progress,
|
||||||
done: job.done,
|
done: job.done,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
url: job.done && !job.error ? `/render/${job.id}/file` : null,
|
url: job.done && !job.error ? `/render/${job.id}/file?token=${encodeURIComponent(job.accessToken)}` : null,
|
||||||
}
|
}
|
||||||
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||||
}
|
}
|
||||||
@@ -73,19 +106,108 @@ app.get('/render/:id/events', (req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/render/:id/file', (req, res) => {
|
app.get('/render/:id/file', requireJobToken, (req, res) => {
|
||||||
const job = jobs.get(req.params.id)
|
const job = jobs.get(req.params.id)
|
||||||
if (!job || !job.filePath || job.error) return res.status(404).end()
|
if (!job || !job.filePath || job.error) return res.status(404).end()
|
||||||
res.download(job.filePath, 'animation.mp4')
|
res.download(job.filePath, 'animation.mp4')
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/render/:id', (req, res) => {
|
app.delete('/render/:id', requireJobToken, (req, res) => {
|
||||||
const job = jobs.get(req.params.id)
|
const job = jobs.get(req.params.id)
|
||||||
if (!job) return res.status(404).end()
|
if (!job) return res.status(404).end()
|
||||||
job.abort.abort()
|
job.abort.abort()
|
||||||
|
if (job.done) scheduleCleanup(job, 0)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
if (err?.type === 'entity.too.large') {
|
||||||
|
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
|
||||||
|
}
|
||||||
|
if (err instanceof SyntaxError && 'body' in err) {
|
||||||
|
return res.status(400).json({ error: 'invalid JSON body' })
|
||||||
|
}
|
||||||
|
console.error(err)
|
||||||
|
return res.status(500).json({ error: 'internal server error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
function originGuard(req, res, next) {
|
||||||
|
const origin = req.get('Origin')
|
||||||
|
if (origin) {
|
||||||
|
if (!ALLOWED_ORIGINS.has(origin)) {
|
||||||
|
return res.status(403).json({ error: 'origin is not allowed' })
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
|
res.setHeader('Vary', 'Origin')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RUWIO-Render-Token')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
|
||||||
|
}
|
||||||
|
if (req.method === 'OPTIONS') return res.status(204).end()
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectLargeBodies(req, res, next) {
|
||||||
|
const len = Number(req.get('Content-Length') || 0)
|
||||||
|
if (Number.isFinite(len) && len > BODY_LIMIT_BYTES) {
|
||||||
|
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRenderToken(req, res, next) {
|
||||||
|
if (!RENDER_TOKEN) {
|
||||||
|
return res.status(503).json({ error: 'render server requires RENDER_TOKEN to be configured' })
|
||||||
|
}
|
||||||
|
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || ''
|
||||||
|
if (!secureEqual(token, RENDER_TOKEN)) return res.status(401).json({ error: 'invalid render token' })
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireJobToken(req, res, next) {
|
||||||
|
const job = jobs.get(req.params.id)
|
||||||
|
if (!job) return res.status(404).end()
|
||||||
|
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || String(req.query.token || '')
|
||||||
|
if (!secureEqual(token, job.accessToken)) return res.status(401).json({ error: 'invalid job token' })
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimitRender(req, res, next) {
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
const bucket = rateBuckets.get(ip)
|
||||||
|
if (!bucket || now - bucket.startedAt > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateBuckets.set(ip, { startedAt: now, count: 1 })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
bucket.count += 1
|
||||||
|
if (bucket.count > RATE_LIMIT_MAX) return res.status(429).json({ error: 'too many render requests' })
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerToken(req) {
|
||||||
|
const auth = req.get('Authorization') || ''
|
||||||
|
return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function secureEqual(a, b) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const left = Buffer.from(String(a))
|
||||||
|
const right = Buffer.from(String(b))
|
||||||
|
return left.length === right.length && timingSafeEqual(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken() {
|
||||||
|
return randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeJobCount() {
|
||||||
|
let count = 0
|
||||||
|
for (const job of jobs.values()) {
|
||||||
|
if (!job.done) count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
function notify(job) {
|
function notify(job) {
|
||||||
for (const sub of Array.from(job.subscribers)) {
|
for (const sub of Array.from(job.subscribers)) {
|
||||||
try { sub() } catch { /* noop */ }
|
try { sub() } catch { /* noop */ }
|
||||||
@@ -112,26 +234,18 @@ function playbackRange(scene) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runJob(job, scene, options) {
|
async function runJob(job, scene, options) {
|
||||||
const fps = Number(options.fps || scene.fps || 60)
|
const { start: rangeIn } = playbackRange(scene)
|
||||||
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
const { fps, duration, totalFrames, crf, preset } = options
|
||||||
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
|
||||||
const duration = Number(options.duration || rangeLen)
|
|
||||||
const totalFrames = Math.max(1, Math.round(duration * fps))
|
|
||||||
const crf = String(options.crf ?? 18)
|
|
||||||
const preset = String(options.preset ?? 'fast')
|
|
||||||
|
|
||||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
|
||||||
|
job.workDir = workDir
|
||||||
const outPath = path.join(workDir, 'out.mp4')
|
const outPath = path.join(workDir, 'out.mp4')
|
||||||
|
const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS)
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
executablePath: CHROME_PATH,
|
executablePath: CHROME_PATH,
|
||||||
headless: 'new',
|
headless: 'new',
|
||||||
args: [
|
args: chromeArgs(),
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--hide-scrollbars',
|
|
||||||
],
|
|
||||||
defaultViewport: {
|
defaultViewport: {
|
||||||
width: scene.canvas.width,
|
width: scene.canvas.width,
|
||||||
height: scene.canvas.height,
|
height: scene.canvas.height,
|
||||||
@@ -190,18 +304,111 @@ async function runJob(job, scene, options) {
|
|||||||
job.done = true
|
job.done = true
|
||||||
notify(job)
|
notify(job)
|
||||||
|
|
||||||
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
|
scheduleCleanup(job, JOB_TTL_MS)
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!job.done) {
|
||||||
|
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
|
||||||
|
}
|
||||||
await browser.close().catch(() => {})
|
await browser.close().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupJob(jobId, workDir) {
|
function scheduleCleanup(job, delayMs) {
|
||||||
jobs.delete(jobId)
|
if (job.cleanupTimer) clearTimeout(job.cleanupTimer)
|
||||||
await fs.rm(workDir, { recursive: true, force: true })
|
job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
async function cleanupJob(jobId) {
|
||||||
console.log(`animatr render server listening on :${PORT}`)
|
const job = jobs.get(jobId)
|
||||||
|
if (!job) return
|
||||||
|
jobs.delete(jobId)
|
||||||
|
if (job.workDir) await fs.rm(job.workDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRenderRequest(scene, options) {
|
||||||
|
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
|
||||||
|
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
|
||||||
|
const width = toBoundedInt(scene.canvas.width, 1, MAX_CANVAS_WIDTH)
|
||||||
|
const height = toBoundedInt(scene.canvas.height, 1, MAX_CANVAS_HEIGHT)
|
||||||
|
if (!width || !height) return { ok: false, error: `canvas must be between 1 and ${MAX_CANVAS_WIDTH}x${MAX_CANVAS_HEIGHT}` }
|
||||||
|
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
|
||||||
|
if (scene.layers.length > MAX_LAYERS) return { ok: false, error: `scene has more than ${MAX_LAYERS} layers` }
|
||||||
|
if (Array.isArray(scene.assets) && scene.assets.length > MAX_ASSETS) return { ok: false, error: `scene has more than ${MAX_ASSETS} assets` }
|
||||||
|
let keyframes = 0
|
||||||
|
for (const layer of scene.layers) {
|
||||||
|
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
|
||||||
|
keyframes += layer.keyframes.length
|
||||||
|
if (keyframes > MAX_KEYFRAMES) return { ok: false, error: `scene has more than ${MAX_KEYFRAMES} keyframes` }
|
||||||
|
for (const kf of layer.keyframes) {
|
||||||
|
if (!Number.isFinite(Number(kf?.time))) return { ok: false, error: 'keyframe times must be finite numbers' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duration = Number(scene.duration)
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION) {
|
||||||
|
return { ok: false, error: `duration must be between 0 and ${MAX_DURATION} seconds` }
|
||||||
|
}
|
||||||
|
const fps = toBoundedInt(options.fps || scene.fps || 60, 1, MAX_FPS)
|
||||||
|
if (!fps) return { ok: false, error: `fps must be between 1 and ${MAX_FPS}` }
|
||||||
|
const range = playbackRange({ ...scene, canvas: { ...scene.canvas, width, height }, duration })
|
||||||
|
const requestedDuration = Number(options.duration || Math.max(0, range.end - range.start))
|
||||||
|
if (!Number.isFinite(requestedDuration) || requestedDuration <= 0 || requestedDuration > MAX_DURATION) {
|
||||||
|
return { ok: false, error: `render duration must be between 0 and ${MAX_DURATION} seconds` }
|
||||||
|
}
|
||||||
|
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
|
||||||
|
if (totalFrames > MAX_FRAMES) return { ok: false, error: `render has more than ${MAX_FRAMES} frames` }
|
||||||
|
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
|
||||||
|
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
|
||||||
|
const preset = String(options.preset ?? 'fast')
|
||||||
|
const allowedPresets = new Set(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'])
|
||||||
|
if (!allowedPresets.has(preset)) return { ok: false, error: 'invalid ffmpeg preset' }
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
scene: { ...scene, canvas: { ...scene.canvas, width, height }, duration },
|
||||||
|
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoundedInt(value, min, max) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
const i = Math.round(n)
|
||||||
|
if (i < min || i > max) return null
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
function chromeArgs() {
|
||||||
|
const args = ['--disable-dev-shm-usage', '--hide-scrollbars']
|
||||||
|
if (process.env.CHROME_NO_SANDBOX === '1') {
|
||||||
|
console.warn('WARNING: CHROME_NO_SANDBOX=1 disables Chromium sandboxing for render jobs.')
|
||||||
|
args.push('--no-sandbox', '--disable-setuid-sandbox')
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntEnv(name, fallback, min, max) {
|
||||||
|
const raw = process.env[name]
|
||||||
|
if (!raw) return fallback
|
||||||
|
const n = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(n) || n < min || n > max) {
|
||||||
|
throw new Error(`${name} must be an integer between ${min} and ${max}`)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSizeEnv(name, fallback) {
|
||||||
|
const raw = process.env[name]
|
||||||
|
if (!raw) return fallback
|
||||||
|
const match = raw.trim().match(/^(\d+)(b|kb|mb)?$/i)
|
||||||
|
if (!match) throw new Error(`${name} must be a byte size like 1048576 or 15mb`)
|
||||||
|
const amount = Number.parseInt(match[1], 10)
|
||||||
|
const unit = (match[2] || 'b').toLowerCase()
|
||||||
|
const mult = unit === 'mb' ? 1024 * 1024 : unit === 'kb' ? 1024 : 1
|
||||||
|
return amount * mult
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`animatr render server listening on ${HOST}:${PORT}`)
|
||||||
console.log(`chromium: ${CHROME_PATH}`)
|
console.log(`chromium: ${CHROME_PATH}`)
|
||||||
|
if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.')
|
||||||
})
|
})
|
||||||
+7
-5
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
PhArrowClockwise,
|
PhArrowClockwise,
|
||||||
PhArrowCounterClockwise,
|
PhArrowCounterClockwise,
|
||||||
@@ -35,7 +35,6 @@ import SvgLayerPanel from './components/SvgLayerPanel.vue'
|
|||||||
import Timeline from './components/Timeline.vue'
|
import Timeline from './components/Timeline.vue'
|
||||||
import TooltipHost from './components/TooltipHost.vue'
|
import TooltipHost from './components/TooltipHost.vue'
|
||||||
import WelcomeDialog from './components/WelcomeDialog.vue'
|
import WelcomeDialog from './components/WelcomeDialog.vue'
|
||||||
import TestOverlay from './devtools/TestOverlay.vue'
|
|
||||||
import ruwioLogo from './assets/logo.png'
|
import ruwioLogo from './assets/logo.png'
|
||||||
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
@@ -71,6 +70,9 @@ const welcomeOpen = ref(false)
|
|||||||
const fullscreenOpen = ref(false)
|
const fullscreenOpen = ref(false)
|
||||||
const aboutOpen = ref(false)
|
const aboutOpen = ref(false)
|
||||||
const helpOpen = 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 TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
|
||||||
const DEFAULT_TIMELINE_HEIGHT = 220
|
const DEFAULT_TIMELINE_HEIGHT = 220
|
||||||
const MIN_TIMELINE_HEIGHT = 120
|
const MIN_TIMELINE_HEIGHT = 120
|
||||||
@@ -422,7 +424,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
|
|||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
testsOpen.value = true
|
if (import.meta.env.DEV) testsOpen.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TAB toggles the side panels (asset sidebar + properties aside)
|
// TAB toggles the side panels (asset sidebar + properties aside)
|
||||||
@@ -610,7 +612,7 @@ async function runDesktopMenuCommand(command: string) {
|
|||||||
aboutOpen.value = true
|
aboutOpen.value = true
|
||||||
break
|
break
|
||||||
case 'test-runner':
|
case 'test-runner':
|
||||||
testsOpen.value = true
|
if (import.meta.env.DEV) testsOpen.value = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -803,7 +805,7 @@ onBeforeUnmount(() => {
|
|||||||
</footer>
|
</footer>
|
||||||
<DocumentSettings v-model="settingsOpen" />
|
<DocumentSettings v-model="settingsOpen" />
|
||||||
<ExportDialog v-model="exportOpen" />
|
<ExportDialog v-model="exportOpen" />
|
||||||
<TestOverlay v-model="testsOpen" />
|
<component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
|
||||||
<FullscreenPreview v-model="fullscreenOpen" />
|
<FullscreenPreview v-model="fullscreenOpen" />
|
||||||
<AboutDialog v-model="aboutOpen" />
|
<AboutDialog v-model="aboutOpen" />
|
||||||
<HelpDialog v-model="helpOpen" />
|
<HelpDialog v-model="helpOpen" />
|
||||||
|
|||||||
+10
-5
@@ -2,6 +2,7 @@ import type { Scene } from '@/types/scene'
|
|||||||
|
|
||||||
const DEFAULT_SERVER = 'http://localhost:6000'
|
const DEFAULT_SERVER = 'http://localhost:6000'
|
||||||
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
|
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 {
|
export interface ServerRenderOptions {
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
@@ -27,7 +28,10 @@ export async function renderOnServer(
|
|||||||
const baseUrl = SERVER_URL
|
const baseUrl = SERVER_URL
|
||||||
const startRes = await fetch(`${baseUrl}/render`, {
|
const startRes = await fetch(`${baseUrl}/render`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
scene,
|
scene,
|
||||||
options: {
|
options: {
|
||||||
@@ -42,18 +46,19 @@ export async function renderOnServer(
|
|||||||
const body = await startRes.text().catch(() => '')
|
const body = await startRes.text().catch(() => '')
|
||||||
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
|
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
|
||||||
}
|
}
|
||||||
const { jobId } = (await startRes.json()) as { jobId: string }
|
const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
|
||||||
if (!jobId) throw new Error('Server did not return a jobId')
|
if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
|
||||||
|
|
||||||
const downloadUrl = await new Promise<string>((resolve, reject) => {
|
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 = () => {
|
const onAbort = () => {
|
||||||
try {
|
try {
|
||||||
es.close()
|
es.close()
|
||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
|
fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
|
||||||
reject(new DOMException('aborted', 'AbortError'))
|
reject(new DOMException('aborted', 'AbortError'))
|
||||||
}
|
}
|
||||||
if (opts.signal) {
|
if (opts.signal) {
|
||||||
|
|||||||
+65
-4
@@ -17,6 +17,11 @@ const SIG_LOCAL = 0x04034b50
|
|||||||
const SIG_CENTRAL = 0x02014b50
|
const SIG_CENTRAL = 0x02014b50
|
||||||
const SIG_EOCD = 0x06054b50
|
const SIG_EOCD = 0x06054b50
|
||||||
const VERSION_NEEDED = 20
|
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 CRC_TABLE = (() => {
|
||||||
const t = new Uint32Array(256)
|
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. */
|
/** Pack a list of file entries into a flat STORE zip byte stream. */
|
||||||
export function zipStore(entries: ZipEntry[]): Uint8Array {
|
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 enc = new TextEncoder()
|
||||||
const localChunks: Uint8Array[] = []
|
const localChunks: Uint8Array[] = []
|
||||||
const centralChunks: Uint8Array[] = []
|
const centralChunks: Uint8Array[] = []
|
||||||
let offset = 0
|
let offset = 0
|
||||||
for (const entry of entries) {
|
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)
|
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 data = entry.data
|
||||||
const crc = crc32(data)
|
const crc = crc32(data)
|
||||||
// Local file header.
|
// Local file header.
|
||||||
@@ -119,27 +132,35 @@ export function zipStore(entries: ZipEntry[]): Uint8Array {
|
|||||||
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
|
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
|
||||||
* data, or compressed entries (we don't ship a deflate path). */
|
* data, or compressed entries (we don't ship a deflate path). */
|
||||||
export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
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()
|
const dec = new TextDecoder()
|
||||||
// EOCD is at least 22 bytes and may have up to 65535 bytes of
|
// EOCD is at least 22 bytes and may have up to 65535 bytes of
|
||||||
// trailing comment; scan from the end within that window.
|
// trailing comment; scan from the end within that window.
|
||||||
let eocdOff = -1
|
let eocdOff = -1
|
||||||
const minOff = Math.max(0, bytes.length - (22 + 65535))
|
const minOff = Math.max(0, bytes.length - (22 + 65535))
|
||||||
for (let i = bytes.length - 22; i >= minOff; i--) {
|
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) {
|
if (dv.getUint32(0, true) === SIG_EOCD) {
|
||||||
eocdOff = i
|
eocdOff = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
|
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)
|
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)
|
const centralOff = edv.getUint32(16, true)
|
||||||
|
ensureRange(bytes, centralOff, centralSize, 'ZIP central directory')
|
||||||
|
|
||||||
const entries: ZipEntry[] = []
|
const entries: ZipEntry[] = []
|
||||||
let p = centralOff
|
let p = centralOff
|
||||||
|
let extractedTotal = 0
|
||||||
for (let i = 0; i < total; i++) {
|
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) {
|
if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
|
||||||
throw new Error('Bad central directory entry.')
|
throw new Error('Bad central directory entry.')
|
||||||
}
|
}
|
||||||
@@ -149,20 +170,30 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
|||||||
const extraLen = cdv.getUint16(30, true)
|
const extraLen = cdv.getUint16(30, true)
|
||||||
const commentLen = cdv.getUint16(32, true)
|
const commentLen = cdv.getUint16(32, true)
|
||||||
const localOff = cdv.getUint32(42, 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) {
|
if (method !== 0) {
|
||||||
throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
|
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 nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
|
||||||
const name = dec.decode(nameBytes)
|
const name = dec.decode(nameBytes)
|
||||||
|
validateZipName(name)
|
||||||
// Re-read the local header to skip its variable-length fields
|
// Re-read the local header to skip its variable-length fields
|
||||||
// before the file data.
|
// 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) {
|
if (ldv.getUint32(0, true) !== SIG_LOCAL) {
|
||||||
throw new Error('Bad local file header.')
|
throw new Error('Bad local file header.')
|
||||||
}
|
}
|
||||||
const lNameLen = ldv.getUint16(26, true)
|
const lNameLen = ldv.getUint16(26, true)
|
||||||
const lExtraLen = ldv.getUint16(28, true)
|
const lExtraLen = ldv.getUint16(28, true)
|
||||||
const dataOff = localOff + 30 + lNameLen + lExtraLen
|
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)
|
const data = bytes.subarray(dataOff, dataOff + size)
|
||||||
// Copy so callers get a standalone Uint8Array they can keep.
|
// Copy so callers get a standalone Uint8Array they can keep.
|
||||||
entries.push({ name, data: new Uint8Array(data) })
|
entries.push({ name, data: new Uint8Array(data) })
|
||||||
@@ -171,6 +202,36 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
|||||||
return entries
|
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
|
/** Magic check — `true` when the first four bytes match the ZIP local
|
||||||
* file header signature (PK\x03\x04). Used by the project-file
|
* file header signature (PK\x03\x04). Used by the project-file
|
||||||
* reader to decide between .ruw (zip) and .json (legacy). */
|
* reader to decide between .ruw (zip) and .json (legacy). */
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.14'
|
export const APP_VERSION = '1.2.15'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user