Security hardening: what was fixed
Worktree: the security worktree (nothing committed; VERSION and CHANGELOG.md untouched). git diff sec-base shows everything: 136 tracked files changed, plus the new files src/build/tests/security.lisp, src/build/targets/tests/ (3 files), src/pm/tests/hardening.lisp and shells/android/templates/data_extraction_rules.xml.
Result #
- Every critical, high and medium finding is fixed by default, except PM-06, which is closed only by two opt-in switches (reason below).
- Full suite in the worktree: 472 tests, 471 passed; the one failure is
e2e-extension-popup(known, environmental). Log:sec/full-suite.log. - The full-suite script only loads systems named
…/tests, so two systems were run separately afterwards:roots-pm-tests: 64 of 64 passed (one run; see "SBCL abort" under Open).roots-data-tests: 38 of 39 passed. The failure isdata-e2e-notes-extension, the sameERR_BLOCKED_BY_CLIENTase2e-extension-popup.
- The nine browser tests of roots/data that were PENDING for every owner now ran: I put the real
@sqlite.org/sqlite-wasm3.54.1-build1 (hashes equal to the pins) intoexamples/notes/node_modules/of the worktree (git-ignored; still there). They found one real bug in the WASM worker, fixed below. - All examples build for all six targets (todo, notes, gallery, hello-lisp × web, pwa, ext, ios, android, term: 24 builds, exit 0) into
integ/ex/.browser-counterandhello.lisphave no roots.lisp and were not built. - Android Kotlin (shell core,
SqlitePlugin.kt,StoragePlugin.kt, the four test classes) compiles against android-36 with Android Studio's kotlinc; JUnit: 34 tests OK. This covers the data owner's and re-tester's Kotlin, which had never been compiled. - Not verified anywhere: behaviour on an Android device or in a WKWebView (gesture rule, frame reply, backup flags), Gradle and Xcode builds, Linux, browsers other than Chromium.
What the integrator changed #
Applied from "needsFromOthers" and "stillOpen":
| Change | Files | Test |
|---|---|---|
roots.lisp keys :csp, :pwa :precache-exclude, :native :backup; :native :plugins defaults to the derived list. This activates Trusted Types, backups off and the derived plugin list, which were dormant | src/build/config.lisp | build-config-security-keys, the targets-sec-* tests (now without their schema shims taking effect) |
Trusted Types also for builds with roots/data: the worker URL goes through a policy roots-data that passes only that URL; the CSP names it. The bundler finds a worker URL handed through one call | runtime/data/wasm.lisp, src/build/targets/dom.lisp, src/bundle/module.lisp | data-e2e-notes-web (whole flow in Chromium under the policy), data-build-every-target, bundle-emit-worker-url-through-a-call, targets-sec-csp-trusted-types |
| WASM worker: a cached statement whose last run failed threw that old error on its next run and did not execute (found by the DATA-16 conformance test, first run here) | runtime/data/worker.lisp | data-runtime-conformance-wasm-chromium |
Dev error page written through write-output-file (DEV-5); dev server header carries the :csp sources | src/build/devserver.lisp | devserver-error-page-is-not-written-through-a-link, devserver-header-carries-the-csp-sources-of-roots-lisp |
migrations/ as a symbolic link is B163 (rest of BB-05) | src/build/data.lisp | build-migrations-are-not-read-through-a-link |
<!-- and a leading --> in module code are C111 (BB-13) | src/parse/lexer.lisp | parse-lexer-html-comments-in-modules |
Unbundled builds (roots dev, --no-bundle) check the project's own modules for redeclarations when they are compiled (BB-15) | src/build/graph.lisp | build-unbundled-modules-are-checked-for-redeclarations |
libsqlite3 is loaded by absolute path on macOS; an empty or relative element in the library path variable is D170 (PM-01 pattern in roots db) | src/data/sqlite.lisp | data-binding-library-is-not-searched-in-the-working-directory |
| roots/data's native driver checks the bridge version like roots/platform (NE-9) | runtime/data/native.lisp | conformance over the Swift plugin still passes; no test of a wrong version |
h(): a handler key inside $attrs becomes a listener instead of an R304 throw | runtime/ui/h.lisp | UI suite passes; no new test |
Launchers drop a relative XDG_DATA_HOME / XDG_CONFIG_HOME / XDG_CACHE_HOME (ASDF died on one) | bin/roots, bin/test-system | checked by hand |
roots build prints the derived plugin list (the summary was given a fresh build object) | src/build/commands.lisp | checked by hand |
*compile-time-lisp* exported from roots.js; plugin names take ASCII digits only | src/package.lisp, src/build/config.lisp | existing tests |
Target security tests are part of roots-build/tests (the separate system file is gone) | src/build/roots-build.asd | they run in the full suite |
| Tests updated for the new defaults | src/data/tests/build.lisp, src/data/tests/e2e.lisp, src/build/tests/native.lisp, src/build/targets/tests/security.lisp | |
| Docs: diagnostic rows, roots.lisp reference, security model | docs/build.md, docs/data.md, docs/packages.md, docs/bundling.md, docs/parser.md, docs/native.md, docs/targets.md, README.md |
Hot paths: nothing was added to runtime/core, runtime/dom or runtime/reactivity by the integration. The worker change is one try per statement run; the roots-data policy is created once per page.
Decisions taken as secure defaults #
These change behaviour for existing apps. Each has a documented way back.
- Trusted Types on for web, pwa and the Roots shells. An app that ships a library which assigns
innerHTMLsets:csp (:trusted-types nil). - Native backups off.
:native (:backup t)turns them on. - Native plugin allow-list derived from imports. An app that calls
RootsNative.call(…)itself lists:native (:plugins (…)); otherwise it getsBRIDGE_UNKNOWN_PLUGIN. - A package's Lisp is refused (B129), no opt-in.
- Another SQLite WASM version is an error (D164) unless pinned in roots.lisp.
Decisions for you #
- PM-06 defaults. Whether
:builds(approvals need a line in roots.lisp) and:verify-ageshould be on by default. Left opt-in: the first breaks CI for every project with approved install scripts until it adds the key, the second adds a registry request per package to every install. import {} from "pkg"now counts as a side-effect import and keeps the module (the re-tester reversed the owner's test).- R303 / R304 / R306 throw into the error handler instead of warn-and-drop.
- iOS file protection for the database is left at the platform default (see NE-3).
Findings #
Status: fixed, mitigated (closed in part or by an option), documented, open.
Build, bundler, parser #
| Id | Severity | Status | Test |
|---|---|---|---|
| BB-01 / DEV-1 | critical | fixed: package Lisp is B129, compile-time forms off for package units, B125 for imports into node_modules by path | build-package-lisp-is-refused, build-compile-time-lisp-gate, build-app-cannot-reach-into-node-modules-by-path |
| BB-02 | high | fixed, including the same bug in the bundler's renaming and the catch-body collision the re-tester found | bundle-minify-var-declared-in-a-block, bundle-minify-catch-body-avoids-the-parameter, bundle-minify-output-check, bundle-emit-names-over-declarators-in-blocks |
| BB-03 / DEV-5 | medium | fixed: output writes and deletes never go through a link, also --out by another spelling, hard links, and the dev error page | build-output-refuses-symlinked-directories, build-output-link-check-knows-the-project-by-another-spelling, build-output-replaces-a-hard-linked-file, devserver-error-page-is-not-written-through-a-link |
| BB-04 | medium | fixed: the two files ship only with the pinned SHA-256. Residual: they are hashed and then read again, so someone rewriting node_modules during a build could swap them | build-sqlite-wasm-files-are-pinned, build-sqlite-wasm-other-version-needs-a-pin, data-build-every-target (real files) |
| BB-05 | low | fixed, migrations/ included | targets-sec-public-symlinked-directory-is-refused, targets-sec-native-sources-are-not-read-through-links, build-migrations-are-not-read-through-a-link |
| BB-06 | low | fixed (B125) | build-package-directory-outside-the-project |
| BB-07 | low | fixed for imports; a bare call to a package function annotated @__NO_SIDE_EFFECTS__ is still dropped without a diagnostic (documented as by design) | build-keeps-a-package-imported-for-its-effects, build-bare-import-detection |
| BB-08 | low | fixed | build-javascript-syntax-error-is-a-build-error, build-unexpected-conditions-are-coded |
| BB-09 | low | mitigated: stack exhaustion is C109 / B192, five quadratic passes removed. Still slow, not crashing: passes in src/template and the Lisp compiler, two small list scans in emit.lisp. B192 does not name the file | bundle-model-deep-and-long-input, bundle-minify-scales, build-deeply-nested-input-is-a-coded-error, build-template-classes-scan-is-linear |
| BB-10 | hardening | fixed at the source (B128, B140); the marker stays one path per line | build-package-manifest-is-validated, build-emit-refuses-unsafe-paths |
| BB-11 | hardening | mitigated: cache and the binary's extracted toolchain must be private (B167), ROOTS_NO_CACHE=1. Entries are not authenticated, so a poisoned cache restored into a private directory is still used | build-disk-cache-must-be-private, build-binary-toolchain-directory-must-be-private |
| BB-12 / DEV-6 | hardening | fixed; the test is a static check, the odd-path run was by hand | build-launchers-load-only-the-toolchains-systems |
| BB-13 | hardening | fixed: documented, and <!-- / --> are C111 | parse-lexer-html-comments-in-modules |
| BB-14 | (verifier) | fixed; a net for mistakes, not a secret scanner | targets-sec-public-key-material-is-never-published |
| BB-15 | (verifier) | fixed (C110), bundled and unbundled. Not reported: (function f(){ let f; let f; }) | parse-scope-redeclaration-error, bundle-model-redeclarations, build-unbundled-modules-are-checked-for-redeclarations |
| BB-16 | (verifier) | not needed with hash pinning | |
| DEV-9 | hardening | documented (docs/build.md "What a build trusts", README). No --no-compile-time-lisp mode |
Dev server #
| Id | Severity | Status | Test |
|---|---|---|---|
| DEV-2 | medium with --host | fixed. Residual by design: a local process can keep taking the 64 slots in 5 s turns | devserver-slow-request-head, devserver-unread-response, devserver-remote-peers-leave-room |
| DEV-3 | low | fixed (32 channels). Residual: a local process can hold all 32; a tab that got 503 does not reconnect until reloaded | devserver-event-clients-are-limited |
| DEV-4 | low | fixed | devserver-stalled-event-client |
| DEV-7 | hardening | fixed; non-HTML files are 'self' / SAMEORIGIN, not 'none' | devserver-cross-site-frames-refused |
| DEV-8 | hardening | fixed as far as a cookie name can: the browser still sends the cookie to other ports of the address | devserver-token-for-remote-peers, devserver-host-needs-the-port |
| NEW-1 | (verifier) | fixed | devserver-events-only-for-the-page |
| NEW-2 | (verifier) | fixed for the dev server. roots dev --target term handles build errors only (all build failures are build errors now) | devserver-watcher-survives-internal-errors |
Package manager #
| Id | Severity | Status | Test |
|---|---|---|---|
| PM-01 | high | fixed; the same pattern in roots db fixed by the integrator | pm-libraries-load-from-absolute-paths, pm-libraries-not-taken-from-the-working-directory, data-binding-library-is-not-searched-in-the-working-directory |
| PM-02 | high | fixed (PM152) | pm-linker-refuses-symlinked-layout |
| PM-03 | high | fixed (PM138, PM140, PM172) | pm-dependency-names-are-validated, pm-lockfile-names-are-validated |
| PM-04 | low | fixed, including Unicode case twins of package.json | pm-tar-one-file-per-path, pm-install-checks-the-manifest-that-is-on-disk |
| PM-05 | medium | fixed (PM142) | pm-lockfile-exotic-resolved-is-tied-to-its-source, pm-lockfile-cannot-redirect-a-file-dependency |
| PM-06 | medium | mitigated, opt-in only. By default a change to roots.lock alone can still pin a version inside the cooldown and approve its install script. Closed with :packages (:builds (…)) and :verify-age t / --verify-age | pm-lockfile-alone-cannot-approve-or-skip-the-cooldown |
| PM-07 | low | mitigated: an index is validated when read; one that lists other valid store files is not detected (documented) | pm-store-index-is-read-as-untrusted |
| PM-08 | low | fixed | pm-gunzip-does-not-trust-the-trailer |
| PM-09 | low | fixed | pm-http-authorization-stays-on-its-origin |
| PM-10 | low | fixed | pm-http-request-line-cannot-be-extended |
| PM-11 | low | fixed as the audit proposed: a project .npmrc can still send a variable as a credential to a host it names, with warning PM171 | pm-npmrc-project-file-is-not-trusted-with-the-environment |
| PM-12 | low | fixed | pm-loopback-is-an-address-not-a-prefix |
| PM-13 | hardening | mostly fixed. Open: sha1-only integrities are accepted without an upgrade to sha512; an optional package can still be marked as for another platform when its tarball is not in the store | pm-install-verify-rebuilds-node-modules, pm-lockfile-cannot-drop-a-package-as-foreign-platform, pm-tls-body-must-end-with-a-tls-close, pm-proxy-must-be-plain-http, pm-package-text-cannot-repaint-the-terminal |
| PM-14 | (verifier) | fixed (PM153) | pm-names-differing-in-case-are-refused |
| PM-15 | (verifier) | fixed; the cache now lives inside the 0700 v1/ | pm-metadata-cache-is-per-registry |
Templates and runtime #
| Id | Severity | Status | Test |
|---|---|---|---|
| XSS-1 | medium | fixed: names judged in lower case, once per name | dom-test.lisp: "attribute names in any case…", "URL attributes in any case…", "names from data through fallthrough attributes…" |
| XSS-2 / V-3 | low | fixed (C211) | tpl-security-attribute-names-any-case |
| XSS-3 | low | fixed, including the backslash bypass (x:/\host) | router-test.lisp "resolve: a target that is no in-app path…"; dom-test.lisp router history test, R407 test |
| XSS-4 | low | fixed | router-test.lisp "resolve: query and params have no prototype…" |
| XSS-5 | hardening | fixed | tpl-security-spread-own-keys |
| XSS-6 / V-1 | low | fixed (C208, R306) | tpl-security-forbidden-elements; core and DOM tests for :is |
| XSS-7 | low | fixed (C224, R307 for data: frame documents) | tpl-security-static-urls; dom-test.lisp "data: URLs are refused where they load a document…" |
| XSS-8 | hardening | fixed for web, pwa, Roots shells and roots/data builds; :csp sources validated. Off for the extension popup and Capacitor. frame-ancestors and SRI limits documented | targets-sec-csp-trusted-types, targets-sec-csp-sources-from-roots-lisp, targets-sec-csp-trusted-types-in-chromium, targets-sec-csp-examples-run-under-trusted-types, data-e2e-notes-web |
| XSS-9 | hardening | documented (docs/templates.md §7, docs/runtime.md §6) | |
| V-2 | (verifier) | fixed: invalid names are dropped with R308. R303 / R304 still throw (decision above) | dom-test.lisp R308 tests |
Targets and native shells #
| Id | Severity | Status | Test |
|---|---|---|---|
| NE-1 | medium | fixed: every precached file is fetched past the HTTP cache with its SHA-256; a failed install keeps the running version and its cache | targets-sec-pwa-update-is-not-filled-from-the-http-cache, targets-sec-pwa-tampered-file-fails-the-install, targets-sec-pwa-failed-reinstall-keeps-the-running-cache |
| NE-M1 | (verifier) | fixed | targets-sec-pwa-missing-file-is-reported, targets-sec-pwa-precache-exclude |
| NE-2 | low | fixed on iOS (authorizer, run over the Swift plugin); Android refuses by the statement's first words: compiled and unit-tested, not run on a device | conformance "native bridge: the sqlite plugin keeps a page inside its own database file"; SqlStatementsTest |
| NE-3 | low | fixed, now active: backups off unless :native (:backup t). Limits: iOS UserDefaults of the storage plugin cannot be excluded; the iOS file-protection class is the platform default | targets-sec-native-backups-are-off-unless-asked-for, targets-sec-native-backup-rules-pass-aapt2 |
| NE-4 | refuted | no fix; docs/native.md no longer claims more than the Android code does | |
| NE-5 | hardening | fixed, now active | targets-sec-native-default-plugins-are-what-the-app-uses, …-include-sqlite-for-roots-data, native-android-project |
| NE-6 | hardening | fixed (B105) | targets-sec-native-plugin-names-map-to-one-class |
| NE-7 | low | fixed | targets-sec-pwa-two-apps-on-one-origin-keep-their-caches |
| NE-8 | hardening | fixed | targets-sec-native-bridge-is-absent-in-frames, BridgeTest |
| NE-9 | hardening | fixed, roots/data's accessor included | targets-sec-native-adapter-checks-the-bridge-version, BridgeTest, Swift checks |
| NE-10 | hardening | mitigated: npm install --ignore-scripts; the app's own Gradle verification metadata is carried over, none is shipped | targets-sec-capacitor-install-runs-no-scripts, targets-sec-native-gradle-verification-metadata-is-carried-over |
| NE-11 | hardening | fixed in code; Android part compiled only | Swift Shell.mayLeave checks |
| NE-12, NE-13 | refuted | no fix | |
| NE-14 | hardening | fixed | JsonTest.unicodeEscapesAreFourHexDigits |
| NE-15 | hardening | fixed; UIKit code not compiled here | none |
| NE-16 | hardening | fixed | BridgeTest, ShellTest, Swift checks |
| NE-17 | hardening | fixed (warning B158) | targets-sec-ext-broad-permissions-are-reported |
| NE-18 | hardening | documented; INTERNET stays (dropping it cannot be checked without a device) |
Data #
| Id | Severity | Status | Test |
|---|---|---|---|
| DATA-01 | medium | fixed: toolchain connections cannot ATTACH; migrations with transaction control are D136, also behind a control character | data-migrations-cannot-write-other-files, data-migrations-cannot-end-their-transaction, data-binding-stays-in-its-file |
| DATA-02 | low | fixed | conformance "live: INSTEAD OF triggers, RAISE(IGNORE) and temp triggers do not hide a write" |
| DATA-03 | low | fixed: integers beyond 2^53 are decimal strings. Still rounded: lastInsertRowid, Capacitor | conformance "integers beyond 2^53 are read as decimal strings…" |
| DATA-04 | low | fixed | data-db-types-hostile-names |
| DATA-05 | low | fixed, migration error messages included | data-db-query-output-is-safe-to-print, data-migration-errors-are-safe-to-print |
| DATA-06 | low | fixed; the launcher part by the integrator | node-suite mode tests, data-db-files-are-private |
| DATA-07 | low | fixed (<name>.sqlite3, old file adopted) | conformance native-bridge test, over the Swift plugin too |
| DATA-08 | low | fixed | conformance "text with a NUL character is stored whole" |
| DATA-09 | low | fixed in roots/data (D112, D113, D114, D111 for NUL). The Android plugin called directly still binds a missing value as NULL | conformance "parameters: NaN and Infinity, a wrong count and a second statement are refused" |
| DATA-10 | low | fixed (D136); Bun scripts stop at the first failing statement | conformance "migrate: a migration that ends its transaction…", "a script stops at the statement that fails…" |
| DATA-11 | hardening | documented | |
| DATA-12 | hardening | documented; no row limit | |
| DATA-13 | hardening | mitigated: trusted_schema off, ATTACH refused (D117). Defensive mode on node:sqlite only | conformance "connections: the schema is not trusted, and ATTACH is refused unless asked for" |
| DATA-14 | hardening | fixed | conformance "a column named proto is an ordinary column" |
| DATA-15 | hardening | fixed and now run in Chromium. Since the DX round a second tab shares the first tab's database (same origin, one connection held by the tab that owns the Web Lock); a tab that cannot share (no Web Locks or BroadcastChannel, or next to a tab of an older build) still gets an in-memory database unless { memoryFallback: false } | data-runtime-conformance-wasm-chromium, data-e2e-wasm-integrity |
| DATA-16 | (verifier) | fixed | conformance "live: a write that fails is still reported…" (all drivers, WASM included) |
| DATA-17 | (verifier) | fixed (D115) | conformance "table(): an undefined where value is refused…" |
| DATA-18 | (verifier) | fixed; data-runtime-conformance-bun passes on this Mac now | data-runtime-conformance-bun |
Open #
- PM-06 (medium): opt-in only, see above.
- SBCL abort in the pm suite. The re-tester saw SIGABRT (
pointer being freed was not allocated, in the cleanup ofsb-posix:stat/lstat) in 4 of 15 runs under load; the pm owner's GC call in one test does not cover it. Root cause unknown; whether a realroots installcan hit it is unproven. My one run passed. Next step: a newer SBCL, or a breakpoint onmalloc_error_break. - Device checks for the Android and iOS shell changes (NE-3, NE-8, NE-11, NE-15) and the Android sqlite rules (NE-2).
- New, from the data re-test, documented only: a
txstatement writtenINSERT OR ROLLBACK(orRAISE(ROLLBACK)) ends the transaction; a callback that catches the error and goes on writes in autocommit. - Hardening left as it is: BB-07 annotation warning, BB-09 slow passes, BB-11 authenticated cache entries, PM-07 index authentication, PM-13 sha1 upgrade, DATA-12 row limit, DATA-13 defensive mode on other drivers, Trusted Types for the extension and Capacitor, NE-10 shipped Gradle metadata, NE-18.
- Small:
write-jsoninsrc/build/util.lispprints doubles with~f(the data command no longer uses it); docs/runtime.md says R304 covers every name starting withon, the code exempts the bare nameon; a staticon-click=attribute compiles and throws R304 at run time.
Behaviour changes for app authors #
Build and config
- New roots.lisp keys:
:csp,:pwa (:precache-exclude …),:native (:backup …),:data (:sqlite-version … :sqlite-mjs-sha256 … :sqlite-wasm-sha256 …),:packages (:builds … :verify-age …). - Pages of web, pwa and the Roots shells require Trusted Types (
:csp (:trusted-types nil)turns it off). Extra origins for fetch, images, fonts, media and frames go in:csp. - A dependency that is Lisp is B129. Importing into
node_modulesby path, or anode_modules/<name>linking outside the project (npm link), is B125. A package without a validpackage.jsonname and version is B128. - A name declared twice in a module is C110, in bundled and unbundled builds.
<!--in module code is C111. import "pkg"andimport {} from "pkg"in app code keep the module despite"sideEffects": false.- Minified output has other short names where a
varsits in a block or catch clause.catch (e) { var e }keeps its name; a clash is B137. - Symbolic links: inside the output directory B141;
public/,icon.png,migrations/,native/<platform>/as links B163. More key and secret files are skipped frompublic/(B164). - roots/data on DOM targets: another
@sqlite.org/sqlite-wasmversion is an error (D164) unless pinned; changed files are D166. - Errors are one coded line (B191, B192,
src/x.js:1:23: C102 …);ROOTS_DEBUG=1shows the backtrace.ROOTS_NO_CACHE=1turns the compile cache off; a cache directory others can write is not used (B167). bin/rootsandbin/test-systemload onlyroots.asd,src/*/*.asd,runtime/*/*.asd.
Dev server
Hostneeds the port. Other sites cannot frame or embed dev-server files. The reload channel is for the page only, at most 32 at once. Time limits on request heads (5 s) and stalled responses. The--hosttoken cookie isroots_token_<port>. Internal errors during a rebuild show in the overlay androots devkeeps running.
Package manager
- A symlinked
node_modules(or.roots,.bin, a package directory) is PM152. Invalid dependency names are PM138 / PM172 / PM140. Names differing only in case are PM153. - Project
.npmrc:${VAR}in a registry URL is PM170, in a credential it warns (PM171); credential keys match host, port and path segments exactly. Authorization is dropped on a redirect to another port or scheme. Anhttps://proxy is refused (PM107). - Unpacked size per package is 512 MB (was 2 GB). Stricter roots.lock checks (PM142, PM146, PM137). Plain http only to
localhostand 127.x.x.x. - macOS: OpenSSL only from Homebrew, MacPorts or an absolute
$ROOTS_OPENSSL_DIR. A library path variable with an empty or relative element is refused (PM101; D170 forroots db). On Linux a leading or trailing colon inLD_LIBRARY_PATHis such an element. - New:
roots install --verify,--verify-age;roots doctorprints the library files.
Templates and runtime
- Attribute names are judged in any case, also when they come from data:
onClickstrings throw R304,srcDoc/InnerHTMLthrow R303,javascript:underHREFis dropped (R307).data:is refused as the document of iframe, frame, embed and object. <component :is="string">refuses script, style, iframe, frame, frameset, object, embed, base, meta and link (R306) unless a component of that name is registered.:srcDoc,:src-doc,:InnerHTMLare C211; static URLs with a tab or newline in the scheme and<object data="javascript:…">are C224;<style>underv-preis C208.v-bind="obj"binds own keys only. Invalid attribute names are dropped with R308.route.paramsandroute.queryhave no prototype. Router targets never leave the app; a refusedpushStateresolvesfalse(R407).
PWA and native
- The service worker install fails if a precached file is missing or not byte-identical to the build (a host that rewrites files stops updates);
updates.onError(message)reports it. Cache names changed, so users download the precache once more. Host files such as_headersandCNAMEare not precached. - Native: plugins derived from imports, backups off, a bridge of another ABI version or a frame gets
BRIDGE_UNAVAILABLE, script cannot open the browser without a user gesture (useopenUrl), iOS link previews off, the database file is<name>.sqlite3(an old<name>is renamed on first open). Extension builds warn about broad permissions (B158).
roots/data
- D113 (value count), D114 (second statement), D112 (NaN, Infinity), D111 (NUL in SQL), D117 (ATTACH, DETACH, VACUUM INTO without
{ attach: true }), D115 (undefinedinwhere), D136 (transaction control in a migration), D154 ({ memoryFallback: false }). trusted_schemais off ({ trustedSchema: true }turns it on). Integers beyond 2^53 come back as decimal strings. A__proto__column is an ordinary property. Live queries also fire for failed writes and trigger writes. On Bun, scripts stop at the first failing statement.roots db queryescapes control characters; existing database files are tightened to 0600;migrateandtypescannot ATTACH.
New diagnostic codes #
| Family | New | Meaning changed |
|---|---|---|
| Build | B129, B137, B158, B167, B191, B192 | B125, B128, B140, B141, B163 (also an error), B164 |
| Parser | C110, C111 | C109 |
| Templates | C208, C211, C224 | |
| Runtime | R308, R407 | R303, R304, R306, R307 |
| Packages | PM138, PM153, PM163 | PM101, PM105, PM106, PM107, PM112, PM121, PM122, PM131, PM137, PM140, PM142, PM146, PM150, PM152, PM170, PM171, PM172 |
| Data | D113, D114, D117, D136, D154, D166 | D111, D112, D115, D152, D164 (warning to error), D170 |
Environment variables: ROOTS_NO_CACHE, ROOTS_DEBUG, ROOTS_OPENSSL_DIR.
Draft CHANGELOG section #
## security hardening
A security audit of the whole tree (dev server, package manager, templates and runtime,
native shells and targets, build and bundler, data), every finding checked by a second
reviewer, then fixed and re-tested. After verification: one critical, four high and eight
medium findings; all are fixed by default except one (roots.lock approvals, below). Each fix has a regression
test. Several defaults change; each has a documented way back.
- **A package's Lisp no longer runs in the toolchain.** A dependency that resolved to a
`.lisp` file, or a `.vue` with `<script lang="lisp">`, was compiled like app code, and
compiling Lisp runs its macros: any package could run Common Lisp as the developer on
every `roots build` and `roots dev`. It is now B129, with no opt-in; while a package file
is compiled the compiler also refuses `(lisp …)`, `defmacro` and `macrolet`. Importing
into `node_modules` by path, and a package directory that links outside the project, are
B125. docs/build.md, "What a build trusts", says what a build trusts (your sources:
completely; packages: not at all).
- **Minifier: a `var` in a block or catch clause could get the short name of a binding
around it**, which changed what the program did (the audit's example made an
`isAllowed` check pass for a forged token). The scope model now records where hoisted variables are
written; the bundler's own renaming had the same hole. A catch clause with a `let` /
`const` in its body and an unused parameter minified to code that did not load. The
minifier's output is now compared with its input scope by scope before it is written.
Short names differ from 0.3.0 for such code; `catch (e) { var e }` keeps its name (B137
when it cannot).
- **Output directory:** `roots build` wrote and deleted through symbolic links inside it,
so a checkout could carry a link that made a build overwrite files elsewhere. Every write
and delete now walks the path without following links (B141), also for the dev server's
error page. `public/`, `icon.png`, `migrations/` and `native/<platform>/` are never read
through a link (B163), and more key material is kept out of `public/` (B164).
- **Package manager:** OpenSSL, zlib and SQLite are loaded by absolute path on macOS (a
library in the working directory used to be picked up by `roots why`); a symlinked
`node_modules/.roots` no longer makes `roots install` delete the link's target (PM152);
dependency names are validated everywhere (`"../../src": "npm:x"` deleted directories;
PM138); a git, URL or file entry of roots.lock must resolve where package.json says
(PM142). Also: no heap exhaustion from a 25-byte gzip, Authorization stays on its origin,
the request line cannot be extended, project `.npmrc` files are not trusted with the
environment, loopback is an address, names differing in case are refused (PM153).
**Not closed by default:** a change to roots.lock alone can still approve an install
script or pin a version inside the cooldown; `:packages (:builds (…))` and `:verify-age t`
(or `roots install --verify-age`) close it for projects that set them.
- **SQLite WASM is pinned by hash.** roots/data shipped `index.mjs` and `sqlite3.wasm` from
whatever `node_modules` was found above the project. The build now ships them only when
their SHA-256 is the pinned one (D166); another version is an error (D164, was a warning)
unless roots.lisp pins it under the new `:data` key.
- **PWA updates are checked.** The service worker fetched its files through the HTTP cache
and never compared them with the build, so an update could be assembled from stale files.
Every file is now fetched past the cache with its SHA-256; a failed install keeps the
running version and reports through the new `updates.onError`. Two apps on one origin no
longer delete each other's caches.
- **Templates and the DOM backend:** the attribute rules were case-sensitive, so `onClick`,
`srcDoc` or `HREF` from data (a spread, fallthrough attributes, `<component :is>` props)
got past them. Names are now judged in lower case, once per name (a Map lookup per write
in place of a chain of string comparisons). `<component :is>` refuses strings naming
document-level tags (R306), the router never renders a link out of the app, route params
and query have no prototype, and invalid attribute names are dropped (R308).
- **Trusted Types.** Pages of web, pwa and the Roots shells are written with
`require-trusted-types-for 'script'`; the only policies are the runtime's own for the
service worker and the roots/data worker URL. `:csp` in roots.lisp adds origins to the
policy (never to `script-src`) and `:csp (:trusted-types nil)` turns the requirement off.
- **Dev server:** bounded request heads, responses and connection slots; at most 32 reload
channels, for the page only; no socket is written while the server lock is held; no
framing by other origins; `Host` needs the port; internal errors during a rebuild no
longer end `roots dev`.
- **roots/data:** `roots db types` and `migrate --check` could write arbitrary files through
a migration (ATTACH); toolchain connections cannot attach now, and a migration with
`BEGIN` / `COMMIT` / `ROLLBACK` is refused before anything runs (D136). At run time ATTACH
needs `{ attach: true }` (D117), `trusted_schema` is off, a wrong parameter count (D113),
a second statement (D114), NaN and NUL are refused on every driver, integers beyond 2^53
come back as decimal strings, live queries see writes made by triggers and by failing
statements, and `roots db query` cannot repaint the terminal. The WASM worker ran a
statement's stale error instead of the statement after a failure; fixed.
- **Native shells:** the plugin allow-list is what the app imports (it was all five
built-ins), app data is excluded from backups unless `:native (:backup t)`, the sqlite
plugin refuses ATTACH and VACUUM INTO, the bridge checks its ABI version and answers
frames with `BRIDGE_UNAVAILABLE`, script cannot leave the app without a user gesture, logs
name scheme and host only. Android and iOS code was compiled and unit-tested; nothing ran
on a device.
- **Parser and build errors:** a name declared twice is C110, `<!--` in a module C111, deep
nesting C109 or B192, a `.js` syntax error one line with file and position, anything
unexpected B191 (`ROOTS_DEBUG=1` for the backtrace). `import "pkg"` keeps the module in a
bundle whatever the package's `sideEffects` says.
- **New codes:** B129, B137, B158, B167, B191, B192; C110, C111; R308, R407; PM138, PM153,
PM163; D113, D114, D117, D136, D154, D166. **New roots.lisp keys:** `:csp`, `:data`,
`:pwa (:precache-exclude)`, `:native (:backup)`, `:packages (:builds :verify-age)`.
**Environment:** `ROOTS_NO_CACHE`, `ROOTS_DEBUG`, `ROOTS_OPENSSL_DIR`.
- **Tests:** 472 in the `…/tests` systems (471 pass here; `e2e-extension-popup` cannot load
an extension on this machine), 64 in the package manager's, 39 in roots/data's (the
extension e2e fails the same way). roots-build's tests now load roots-pm.
- **Open:** the roots.lock default above; an intermittent SBCL abort in the package
manager's test suite under load, cause unknown; device runs of the shell changes; no
row limit in roots/data; cache entries and the store index are not authenticated.