Skip to content

Security: egatari/egatari-studio

Security

SECURITY.md

Security

See also the threat model (what is protected, from whom, and where each protection lives in the code).

Trust model: a deck is code

Treat a working folder like a source-code repository, not like a document. Build, check, render or serve only decks you trust.

  • Scripts run. Inline <script> in deck.html, components (components/<name>/index.js, deck-local ones first) and component attribute expressions (registry components up to v1.0.1, which are yanked; from components v1.0.2, the default, m-sweep and m-curves evaluate formulas with an arithmetic parser and never run them as code) run in the browser that opens the built HTML, and in the headless Chrome used by kit/check.mjs, tools/pdf.mjs, render/ and the tests (Playwright).
  • Build reads local files, inside the working folder only. /*INCLUDE:…*/, {{JSON:…}}, {{FIG:…}}, narration.md, timeline.json, macros.tex, audio/manifest.json and referenced assets (<img src>, <link rel="stylesheet">, CSS url(), …) are resolved relative to the working folder (INCLUDE also searches kit/). Paths that leave the folder, through ../, an absolute path or a symbolic link, are refused with a warning and nothing is read. Components (components/<name>/index.js) and theme files (theme.json, tokens.css, logos, and every theme reached through extends, at any depth) are checked by real path against their authorized root before they are read: the working folder for deck-local ones, the repository's components/ or themes/ for registry ones, and the folder itself for each of your theme folders (EGATARI_THEMES, ~/.egatari/themes; real path, computed once). A deck-local theme can extend only themes in the working folder, your theme folders or themes/, so extends cannot leave through ../, an absolute path or a link. A refused file is not read and the build warns (refused: …); a refused theme falls back to themes/default, a refused deck-local component is not replaced by the registry one. Links that stay inside the folder are followed for reads. What you name on the command line or in the environment is trusted: the deck file itself, a --theme, an -o output path, the folders in EGATARI_THEMES.
  • Build size is bounded. Includes, embeds (data: URIs, stylesheets, scripts, @import) and formulas are counted while they are expanded, and the build stops with build-too-large as soon as a budget is exceeded (defaults: 10 000 includes and embeds, 64 Mi characters after preprocessing, 256 Mi after embedding, 500 000 tags before each parse, 20 000 formulas; opts.limits changes them). Every file the build reads — source, includes, embeds, theme files and logos through every extends, components, narration, audio, fonts, runtime — counts toward the same include count and one aggregate read budget (readChars, 256 Mi), charged from the file size before the file is read, so a 1 GiB logo, stylesheet, component or audio file is refused without being read; the logos and the whole output are size-checked before they are joined. The size is only an estimate: each read is also charged with the bytes actually read, and a file larger than its reported size is refused. Only regular files are read: on Node the build checks the path (lstat) and the opened descriptor (fstat), opens non-blocking and reads at most the allowed bytes from that descriptor, so a FIFO, device or socket in the working folder is refused (build-not-a-file) instead of blocking the build. Sizes are always counted in bytes, never in UTF-16 code units. Audio compression reads the source .wav once, up to the size charged for it, and gives ffmpeg a copy of those bytes in a private temporary folder instead of reopening the path. The portable export (--portable, --play-only, the editor's export) uses the same budget as its build for everything it reads itself (narration, copied sources, voice.json, egatari.json, audio before and after compression, segment metadata) and checks the whole file before it builds each part it inserts: archive chunks from their projected size, the index from a lower bound before serializing (also against the reader's indexBytes), and the readable layer counted in escaped characters while it is built; base64 is encoded last. In the editor app, the build and the classification of an opened deck run in a worker that is terminated after 60 s. The editor reads only the files a build actually references (never the rest of the folder), at most 8 at a time and within the same budget (reserved from the folder listing before reading, each read capped at its listed size — a file that grew is refused — and checked from File.size again right before reading and from the bytes read); messages are bound to the worker that sent them, so a reply from a stopped worker is dropped and never reaches its replacement, and reads still queued for a stopped worker are not started.
  • Build output is self-contained. Local assets are embedded as data URIs. http(s) references are left as they are, and the build warns that the deck is then not fully offline.
  • The dev server is local-only. server/dev.mjs listens on 127.0.0.1 only, accepts only Host: 127.0.0.1:<port> or localhost:<port> (against DNS rebinding), and writes (text edits, narration, comments) need JSON, a same-origin request (requests with Origin: null or a cross-site Sec-Fetch-Site are refused) and a per-run token. It writes only inside the working folder (deck.html, narration.md, comments.json, .cache/edits/), through one helper (kit/safe-fs.mjs) that refuses when the file or any folder between the working folder and it is a symbolic link (even one pointing inside, and dangling ones), checks that the real parent is inside the working folder, and writes an exclusive temporary file in the same folder that is synced and renamed over the target. Backup cleanup deletes only regular files named like backups (<time>-<name>, the same rule as the editor app's cleanup), in a backup folder that is itself checked; other files in .cache/edits/ are never deleted. It reads narration.md, comments.json and deck.html only if their real path is inside the working folder, only regular files and within fixed limits (16 MiB, 4 MiB, 32 MiB; a larger file gets 413 unread, a save that would exceed a limit gets 413 unwritten), streams files under /deck/ only up to their stat size, and watches the working folder, audio/, data/, figs/ and components/ only if they are real folders inside it (a symbolic link is not watched, with a warning). The same helper is used by npm run ids, npm run portable -- unpack / pack and the build's out/ and egatari.json writes. Not covered: the TTS and video tools (tts/, render/, tier 2) and npm run new write without these checks, and a race in which another local process swaps a folder for a link between the check and the write is not prevented (the threat is a link placed in a working folder, not a concurrent process). Do not expose it to a network or put it behind a proxy.
  • TTS output. tts/synth.py stops before writing audio/, .cache/voices/ or the files in them through a symbolic link (since 0.2.2). It does not limit what it reads: narration.md, voice.json and a reference voice named by path in voice.json may be anywhere, by design.
  • While you edit, the deck cannot write. In the dev server and in the editor app the deck (its inline scripts and components) runs in a sandboxed iframe with an opaque origin (sandbox="allow-scripts allow-modals", no allow-same-origin) and a Content-Security-Policy with connect-src 'none' and every other resource limited to data: / blob: (the dev server also allows its own /deck/audio/ for audio and frames only its exact preview endpoint /preview, which it serves only into its own frame page — with a capability issued with that page and Sec-Fetch-Dest: iframe — and never as a top-level document; every file it serves from the working folder carries a CSP sandbox and is served as an inert download unless it is audio, video, an image or a font). The editor app puts that policy in a trusted prefix of the preview document, before any byte of the deck. The editing UI (server/editor-ui.js) runs outside the deck: in the dev server's frame page, which alone holds the per-run token, and in the editor app's own page, which alone holds the folder handles. The deck can only send hints (server/hints.js: which element or step to open, a note position, the keys e / n / c / Esc, its position); a hint carries no text to write. The editing UI reads the source itself, checks the range and hash against the current build, shows the text in its own card, and writes only when you press Save (or ⌘/Ctrl+Enter) there. The rights prompt and read-only modes of the editor app apply to those writes. The point of a note (its pin, in slide coordinates) comes from a hint too, so it is checked and clamped to the slide on both sides (kit/core/comment-core.mjs); a forged point only moves the marker, which the editing UI draws over the preview from the iframe's own size, without taking clicks. Images of what a comment points at are never taken from the preview: tools/comment-shots.mjs renders the step again from the source. tools/hostile-deck.mjs is a deck that tries to write in every way we know of; the dev-server and editor-app tests check that it changes nothing.
  • A deck's own code still runs in the preview. It cannot write or reach the network, but it can show anything inside the preview. Anything inside the slide area can be drawn by the deck; trust only controls in the editor's own panels. All editing controls (edit-mode bar, source box, note box and list, narration drawer) are drawn outside the slide area, in a side panel under an orange marker bar (“Editing — app” / “Editing — dev server”); while you edit in a trusted field (source or note box open, typing in the narration drawer or unsaved narration), the preview is inert so it cannot catch your clicks, and hints from the deck do not switch the panels. A fake card drawn by the deck cannot save. Before the editor app runs a deck's own code, it says so and starts the preview (and the presenter view) only after you confirm. Whether a deck has code is decided from its content, never from the profile a single file (.ega.html) declares, and from the built document — the source after every include, including files of any extension, the same bytes that are then shown: scripts without a data type, <meta http-equiv="refresh">, event-handler attributes, javascript: URLs, srcdoc, embedded documents, included SVG or HTML with any of these, JavaScript files, and component formulas that are not plain arithmetic. A file declared “core” whose content has code is treated as having code, with a warning.
  • Untrusted files are read through one bounded reader. Every read of deck, workspace or portable-file data goes through kit/bounded-read.mjs (Node: regular files only, checked by lstat and on the opened descriptor, opened non-blocking, refused before reading if larger than the limit, at most the limit read) or app/bounded-read.js (browser: File.size checked before arrayBuffer(), the bytes read checked after; never File.text()). Callers pass explicit limits; the default is 32 MiB. tools/test-raw-reads.mjs (part of npm test) fails on any readFileSync, fs.readFile, createReadStream, text(), arrayBuffer() and similar outside these two modules unless it is in an allowlist of reads of trusted repository files, each with a reason. Not covered: the Python TTS tools (tts/) read with Python's own I/O (see TTS output below).
  • Opening a single file allocates within limits. Sizes are checked before allocation (npm run portable -- unpack refuses a file over the format's 100 MiB from the opened file's size, before reading or copying it), the HTML is parsed with budgets for elements, attributes and nesting counted while parsing, and the editor app stops a read that takes longer than 30 s.
  • Registry names in single files are identifiers. components[].name and themes[].name of registry entries must match ^[a-z][a-z0-9-]{0,63}$ (format spec); the reader refuses others (invalid-index), and npm run portable -- unpack looks a registry component up by name and SHA-256 in components.lock.json and compares it only through a contained regular-file read of vendor/components/<name>/<version>/index.js (a link out of vendor/components/ counts as missing). Library components are copies of egatari/components releases; npm run components:update checks the release tarball and every file against the release's manifest.json (SHA-256) before writing, never replaces a version it already has, and the build never fetches components. A name such as ../../private no longer reveals whether, or with which hash, a file outside the registry exists.
  • Classification parses as a browser with scripting on. <noscript> (also <noembed>, <noframes>, <iframe> text) is raw text and ends at the first closing tag, so markup that a scripting-disabled parser would read as an attribute value cannot hide a script that runs. The build's boundary search, the portable reader and tier-A edits use the same mode.
  • Links in folders opened in the editor app. The memory and OPFS workspaces cannot contain links. For a folder picked with the File System Access API, Chromium (tested with 155 on macOS) does not list links inside the folder, cannot open them by name and cannot write through them (tools/test-security.mjs checks this); the API offers no way for the app to detect links itself, so this relies on the browser.
  • A pasted AI reply is an untrusted deck. The chat AI choices of “New…”, “Insert slide…” and “Revise…” in the editor app only parse the pasted text in the page (kit/core/llm-import.mjs: finding the code blocks, or telling a block pasted on its own from its content; kit/core/slide-ids.mjs: adding ids with parse5, nothing executed). The source is made into a single file in the build worker (same limits and timeout) and opened through the same path as a file you open: the content decides whether it has code, so a <script> or any other code in a reply asks before the preview starts, and Cancel opens nothing. Replacing a folder deck writes deck.html and narration.md with backups and asks about code again. The fix request only reads the current source and copies text to the clipboard.
  • Narration audio in the editor app. The microphone (getUserMedia, MediaRecorder), file import, post-processing and MP3 encoding run only in the editor's own page (recording mode, app/rec-mode.js, and the Audio panel, app/audio-panel.js). The preview cannot start a recording or a regeneration, and no audio is ever taken from the preview (for playback the editor sends the preview the bytes of the audio files that audio/manifest.json lists for its steps, read with the same limits, and the preview's trusted shim turns them into blob: URLs of the preview document); while recording mode is open the preview is inert, and while the panel makes or writes audio, hints from the deck do not change its step. The panel reads the step's audio and audio/manifest.json through the workspace with limits (each audio file 64 MiB, the manifest 16 MiB, an imported file 64 MiB checked before reading, a take at most 600 s) loads the MP3 encoder only from the editor's own site, and only when it encodes MP3 (app/mp3-loader.js imports vendor/lame-wasm/index.js of the built site: an unmodified copy of dist/index.js from the npm package lame-wasm, pinned in package.json, that tools/build-app.mjs makes; the WebAssembly is embedded in that file and nothing else is fetched; the single-file editor opened from file: has no encoder and saves WAV), records no device or file names, and writes only audio/<rec|tts|imp>_<hash>.mp3 (or .wav) and audio/manifest.json (backed up to .cache/edits/ first, refused if the manifest changed since it was read) through the same write paths as other edits; the file name in a manifest entry is used only if it is a plain relative path under audio/. Writes of a single file go to its in-memory folder and reach the file only through Save (tier A). The editor page's policy still allows no network. Browser TTS runs in a separate window of the same site (lab/tts-helper.html), opened only after you confirm the download; it loads pinned libraries from jsDelivr and the model from Hugging Face, like the lab page, and is sent only the text to read and the voice name. The editor accepts audio only from that window (e.source, same origin, the request id), checks the sample rate and length, and processes it like a recording. That window is same-origin with the editor, so it is trusted as the lab page is: a compromised jsDelivr or Hugging Face file running there could reach the editor's storage.
  • Attachments are download-only. Files under attachments/ (format §3.8) are packed into a single file but never built, parsed for code, opened or run: the code check skips them, the editor lists them in its own panel without opening them or passing them to the preview, and the player of a single file only offers a download after checking the index hash and the attachment's size and SHA-256, as an application/octet-stream Blob through a link with download (no navigation, no new tab; an .html, .svg or .pdf attachment is saved, not rendered). Inside a host's sandboxed frame without allow-downloads the download is simply blocked. Packing reads only regular files under attachments/ (links are not followed) within the build's read budget and the attachment limits (10 MiB each, 20 MiB together, 100 files), and only the formats each role allows (format §3.8): the type comes from the extension and is checked against the content without running anything, within decompression limits (kit/core/attach-content.mjs). Executables (and, in any role, the extensions common operating systems run or open as scripts, such as .js, .bat, .ps1 or .command), HTML, SVG, archives, macro-enabled or legacy Office, audio and video, PDFs with JavaScript, embedded files, automatic actions other than going to a place in the document, link actions other than GoTo and URI (no Launch, SubmitForm, ImportData, GoToR, GoToE, Rendition), or encryption that needs a password, Office files with macros, ActiveX, embedded objects, external links, DDE, data-fetching functions, executing field codes or remote templates, and files of two types are not packed; the editor refuses them with the reason, and the player offers only the formats the role allows (extension and media type must agree), saves under the path's file name, notes that code should be run only if its source is trusted, and checks the first bytes before saving. An attachment is still a file from the author: what it does once you open it on your computer is outside Egatari.
  • Saves stay with their deck. The editor app rechecks after every wait that the deck a save started on is still open, aborts the file write otherwise, and marks a deck saved only if no edit happened during the save.
  • Links that leave the lesson. A deck's link to another site is never followed by a click alone: the player (and a single file) shows the destination's host and full address first and opens it only on confirmation, in a new context with noopener,noreferrer, asks every time when the link text names another host, the destination is not https:, or it downloads, and refuses javascript:, data:, file: and every scheme but http(s), mailto and tel (format spec §11; kit/core/link-policy.mjs, tools/test-extlinks.mjs). The editor's preview opens nothing outside.
  • TTS and video (tier 2) send narration text to the TTS server in IRODORI_URL (default http://localhost:8882). Point it only at a server you run or trust.

comments.json, .cache/, .env* and *.local.json are gitignored so that review notes, edit history and local settings are not published by accident.

Components yanked by the registry

Components v1.0.0 and v1.0.1 are yanked (replacement: v1.0.2): their m-sweep and m-curves ran formulas from a lesson with new Function. Their tags and assets stay (a tag is never moved); every release's manifest.json from v1.0.3 on lists them under yanked with the replacement, and npm run components:update -- v1.0.3 records that in components.lock.json. Following spec §6:

  • Never silent. A deck that pins (components in egatari.json) or a single file that records a yanked version is built with exactly that version and a warning (kit/build.mjs, kit/check.mjs; stats yanked). The editor app shows a notice and a Rebuild with 1.0.2 button; only pressing it changes egatari.json (and the single file's record), and a note lists which identity hashes changed.
  • The editor page forbids eval. Its Content-Security-Policy (app/index.html, the hosted editor and the single-file editor.html) has no 'unsafe-eval' (only 'wasm-unsafe-eval', which the MP3 encoder's WebAssembly needs and which allows no eval or new Function); tools/test-yanked.mjs listens for CSP violations on the page. The preview frame is a sandbox without allow-same-origin (opaque origin: no access to the editor page, its storage or the folder handles) and inherits the page's policy, so inside the editor a yanked m-sweep / m-curves shows its placeholder instead of running its formula. PREVIEW_CSP (app/preview-doc.js) and the dev server's deck document (server/dev.mjs, a top-level sandboxed document) keep 'unsafe-eval' so that yanked versions still play there; lab/tts-helper.html is a separate page of its own with its own policy (wasm-unsafe-eval for the speech model).
  • The code scan (kit/core/code-scan.mjs) still treats formulas outside plain arithmetic as code in documents that record a yanked version.

Node versions

Use a Node.js release that includes the latest security fixes. The floor is the security release of 2026-10-20/21 (among others, Brotli and TLS certificate checking issues): 22.23.4, 24.22.1 or 26.12.1, or later on the same line. Node 20, 23 and 25 are end-of-life and not supported.

  • package.json engines is ^22.23.4 || ^24.22.1 || >=26.12.1 (npm warns on an older Node; it does not refuse).
  • CI asks actions/setup-node for a range, not a bare major ('>=22.23.4 <23', and '>=24.22.1 <25' for the Node 24 job). With a bare 22 the action takes whatever 22.x the runner image has cached, which lags behind: the Linux jobs of 2026-10-24 ran 22.23.3 and 24.21.0. With the range an older cached copy does not match, and the action downloads a patched one.

Dependency updates (cooldown)

Dependabot (.github/dependabot.yml) proposes a new npm release only after it has been public for 7 days (14 days for a major version), and a new action release after 7 days. This gives a hijacked release (like the npm worms and preinstall payloads of 2025-2026) time to be found and unpublished before it reaches a pull request here. Security updates are not delayed: Dependabot does not apply the cooldown to them. A human still reads every update.

Install scripts

CI installs with npm ci --ignore-scripts: no preinstall, install or postinstall script of any dependency runs. You can install the same way locally.

  • Packages with an install script in package-lock.json (npm query ':attr(scripts, [install]), :attr(scripts, [preinstall]), :attr(scripts, [postinstall])', or "hasInstallScript": true): only esbuild (dev dependency, postinstall: node install.js). It is not needed: the binary comes in the platform package (@esbuild/<os>-<arch>, an optional dependency) and esbuild finds it at run time; the script only checks that binary and, on some systems, copies it over the JavaScript launcher to start a little faster. If a future esbuild ever needs it, run npm rebuild esbuild after npm ci --ignore-scripts for that package alone.
  • Playwright's Chromium is not an install script: CI downloads it in its own step (npx playwright-core install --with-deps chromium).
  • tools/site-versions.mjs asset (the release asset) also installs with --ignore-scripts.
  • Exception: the Colab notebook (colab/egatari-tts.ipynb) installs onnxruntime-node with its postinstall, which downloads the CUDA execution provider from NuGet; the GPU path does not work without it. The versions there are pinned (tools/make-colab-notebook.mjs).

When a dependency is added or bumped, check the list again (npm query as above); a new install script needs a reason here and a targeted npm rebuild <package> step in the workflows.

Residual risks (accepted)

After seven independent review rounds of this version line, no high-severity issue is known. The following are known limits that we accept for now; they concern resource use and local trust, not reading or writing outside a deck. Reports that show one of them leading to disclosure or to writing outside the deck are still welcome.

  • Shared temporary caches assume a trusted local computer. Audio compression works in a private folder (mode 0700, inputs created exclusively with mode 0600, removed afterwards; ffmpeg is started without a shell), but the shared caches for compressed audio and subset fonts in the system temp folder are not checked for ownership or links, and cached entries are reused without re-checking their content. Another local user could plant entries there.
  • The raw-read check is a maintenance guard, not enforcement. tools/test-raw-reads.mjs looks for direct reads outside the bounded readers by text. It can miss unusual spellings (for example extra spaces, computed property access, FileHandle.read()), and it does not scan tests, vendor/ or non-JS code (the Python TTS tools read without these limits).
  • Network reads in the TTS test page and the editor's TTS window are not size-limited before buffering. lab/irodori.js and lab/tts-engine.mjs check model sizes after download (Kokoro's files are fetched by kokoro-js without a size check), and cached responses and preset voices have no cap. (tools/get-fonts.mjs downloads from a fixed commit with a size limit and a SHA-256 check.)
  • Budgets limit what a deck can make the tools read and produce, not total process memory or time. Several copies of the same data can exist at once during a build or export, some escaping is allocated before the exact size check, and ffmpeg has no timeout or output limit.
  • Refused writes are not always free of side effects. A dev-server save can create and prune a backup before the save itself is refused for size; npm run ids writes its files one after another, not as one transaction.
  • Another local process changing the working folder while a tool runs (for example swapping a folder for a link between a check and its use) is out of scope.

Reporting a vulnerability

Please report security issues privately through GitHub's Report a vulnerability button (Security → Advisories) on https://github.com/egatari/egatari-studio, rather than in a public issue. Include the affected file or command, a minimal deck that reproduces it, and the impact. You should get a reply within two weeks. There is no bug bounty.

Learn more about advisories related to egatari/egatari-studio in the GitHub Advisory Database