From 759b76b43d68b2238893ab628813879673ab73b6 Mon Sep 17 00:00:00 2001 From: authorTom <63931206+authorTom@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:58:01 +0100 Subject: [PATCH] Harden the server and the app for production, and give Deckle a test suite Security: - Refuse any path that resolves into .deckle-state, not just one whose raw first segment matches; "./.deckle-state/assistant.json" read the API key. - Stop trusting X-Forwarded-For from anyone for login and token throttling. DECKLE_TRUST_PROXY opts in, counting hops from the right; the attempts table is capped. - Open files before writing headers and stream through pipeline, so an unreadable file is a 500 rather than a crashed process. - Derive the session signing key from the password too, compare passwords as digests, tolerate malformed cookies, require the app header to sign out. - Send a Content-Security-Policy with every response. - Render bookmark hrefs only for http(s) URLs; cap ZIP inflation at the size the archive claims; validate AI tool, memory and inbox paths in the app. - npm audit fix for linkify-it, postcss, nanoid and browserslist. Data loss: - Serialise note autosave flushes so an older batch can't land last. - Move tasks and bookmarks onto one useDataFile hook: no save before load, saves in order, pending changes written to their own library on switch. Unreadable data files are copied aside in the app and refused by the API. - Lock queue index updates so concurrent runs don't drop or revert rows. - API: roll recurring tasks forward on completion, validate a PATCH before moving the note, reject non-object bodies, real dates, hex colours; end an export whose client disconnected. Tests: 285 Vitest tests covering the server through its real handler (now built by server/app.mjs), /api/v1, every storage backend including the remote adapter against a live server, the queue, AI tools and the React hooks. CI runs them before the build. Co-Authored-By: Claude Opus 5 --- .dockerignore | 1 + .env.example | 10 + .github/workflows/ci.yml | 5 + CHANGELOG.md | 90 +- README.md | 33 +- compose.yaml | 4 + package-lock.json | 1504 +++++++++++++++++++++++--- package.json | 10 +- server/api-auth.mjs | 42 +- server/api.mjs | 191 ++-- server/app.mjs | 473 ++++++++ server/auth.mjs | 102 +- server/dates.mjs | 74 ++ server/index.mjs | 434 +------- server/library-api.mjs | 131 ++- server/library-store.mjs | 68 +- server/openapi.mjs | 28 +- server/settings-store.mjs | 13 +- server/static.mjs | 62 +- server/throttle.mjs | 111 ++ server/zip.mjs | 38 +- src/ai/tools.ts | 76 +- src/bookmarks/store.ts | 32 +- src/bookmarks/url.ts | 17 + src/bookmarks/useBookmarks.ts | 65 +- src/components/BookmarkList.tsx | 6 +- src/fs/appData.ts | 81 +- src/hooks/useDataFile.ts | 152 +++ src/hooks/useNotes.ts | 21 +- src/lib/unzip.ts | 59 +- src/memory/store.ts | 7 +- src/queue/runner.ts | 2 +- src/queue/settings.ts | 14 +- src/queue/store.ts | 48 +- src/tasks/store.ts | 36 +- src/tasks/useTasks.ts | 79 +- test/client/components.test.ts | 51 + test/client/helpers.test.ts | 192 ++++ test/client/hooks.test.ts | 192 ++++ test/client/library.test.ts | 208 ++++ test/client/memory-and-tools.test.ts | 180 +++ test/client/queue.test.ts | 155 +++ test/client/remote.test.ts | 136 +++ test/client/stores.test.ts | 100 ++ test/client/zip.test.ts | 149 +++ test/helpers/memfs.ts | 234 ++++ test/helpers/server.mjs | 63 ++ test/server/api-auth.test.mjs | 71 ++ test/server/api.test.mjs | 344 ++++++ test/server/auth.test.mjs | 127 +++ test/server/dates.test.mjs | 51 + test/server/http.test.mjs | 314 ++++++ test/server/library-api.test.mjs | 164 +++ test/server/library-store.test.mjs | 186 ++++ test/server/paths.test.mjs | 88 ++ test/server/stores-misc.test.mjs | 104 ++ test/server/throttle.test.mjs | 82 ++ test/server/zip.test.mjs | 65 ++ vitest.config.ts | 21 + 59 files changed, 6427 insertions(+), 969 deletions(-) create mode 100644 server/app.mjs create mode 100644 server/dates.mjs create mode 100644 server/throttle.mjs create mode 100644 src/hooks/useDataFile.ts create mode 100644 test/client/components.test.ts create mode 100644 test/client/helpers.test.ts create mode 100644 test/client/hooks.test.ts create mode 100644 test/client/library.test.ts create mode 100644 test/client/memory-and-tools.test.ts create mode 100644 test/client/queue.test.ts create mode 100644 test/client/remote.test.ts create mode 100644 test/client/stores.test.ts create mode 100644 test/client/zip.test.ts create mode 100644 test/helpers/memfs.ts create mode 100644 test/helpers/server.mjs create mode 100644 test/server/api-auth.test.mjs create mode 100644 test/server/api.test.mjs create mode 100644 test/server/auth.test.mjs create mode 100644 test/server/dates.test.mjs create mode 100644 test/server/http.test.mjs create mode 100644 test/server/library-api.test.mjs create mode 100644 test/server/library-store.test.mjs create mode 100644 test/server/paths.test.mjs create mode 100644 test/server/stores-misc.test.mjs create mode 100644 test/server/throttle.test.mjs create mode 100644 test/server/zip.test.mjs create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index b420503..fc30c6e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ dist .git .github .claude +test *.md LICENSE Dockerfile diff --git a/.env.example b/.env.example index bfb0de4..a1de091 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,16 @@ DECKLE_SESSION_SECRET= # How long a sign-in lasts, in days (default 30). # DECKLE_SESSION_TTL_DAYS=30 +# Behind a reverse proxy — Caddy, Traefik, nginx, a Cloudflare Tunnel? Say so, +# so sign-in throttling sees each visitor's address rather than the proxy's. +# "true" means one proxy; use a number for a chain (Cloudflare in front of +# Caddy is 2). Leave it unset when browsers connect to Deckle directly: +# X-Forwarded-For is then ignored, because any client can write it. +# +# Unset behind a proxy, every visitor shares the proxy's address — so ten +# wrong passwords from anyone lock everyone out for fifteen minutes. +# DECKLE_TRUST_PROXY=true + # Optional. Where Deckle keeps its own state — currently the assistant settings # shared by every device that signs in, API key included. Defaults to # ".deckle-state" inside the library directory, which is the volume you already diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c25f70..16381ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,4 +43,9 @@ jobs: # fails if the two have drifted, which is the point of running it here. - run: npm ci + # The server through its real HTTP handler, the API, every storage path, + # the assistant's tools and autosave. Before the build, because a failing + # test says more about a change than a bundle that compiles. + - run: npm test + - run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index fae2ff7..898148b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,95 @@ here. ## [Unreleased] -Nothing yet. +A hardening release: security fixes, several ways data could be lost, and a +test suite. **If Deckle sits behind a reverse proxy, read the first item under +Changed** — one new setting keeps sign-in throttling working as it should. + +### Security + +- **The assistant's API key could be read through the file API.** Shared + assistant settings live in `.deckle-state`, which the library API is meant + to refuse — but a path written as `./.deckle-state/assistant.json` got past + the check. Any path that resolves into that folder is now refused, including + other capitalisations on case-insensitive disks and a `DECKLE_STATE_DIR` set + somewhere else inside the library. Reaching it took a signed-in session, but + it also put the key within reach of the assistant's read-only tools. +- **Password guessing was not really throttled.** Sign-in and API-token + throttling believed `X-Forwarded-For` from anyone, so a script could claim a + new address on every guess and never be locked out — or lock a real person + out by claiming theirs. The header is now ignored unless you set + `DECKLE_TRUST_PROXY`, and the number of addresses tracked is capped. +- **Changing `DECKLE_PASSWORD` signs everyone out**, even with a fixed + `DECKLE_SESSION_SECRET`. Sessions issued under the old password used to stay + valid until they expired. +- **One unreadable file could take the server down.** Downloading a note the + container isn't allowed to read — a root-owned file in a bind mount — crashed + the process for every user. That request now gets an error and nothing else + notices. +- **A Content-Security-Policy on every response.** Only the app's own scripts + run, whatever finds its way into the page. +- Bookmark links are drawn only for `http` and `https` URLs. A `javascript:` + URL in a synced or hand-edited `bookmarks.json` no longer runs when clicked. +- A ZIP import can no longer exhaust the tab's memory by claiming small sizes + and inflating to gigabytes. +- The assistant's file tools refuse `..` and absolute paths and won't write into + hidden folders, whatever the storage underneath would allow; its memory tools + can't climb out of the memory folder. A queued run's inbox fence no longer + counts `Assistant inbox/../Projects/plan.md` as inside the inbox. +- Signing out requires the app's own request header, like every other + state-changing call, so another site can't sign you out. +- Checking the password no longer reveals its length through timing. +- Updated dependencies with published advisories (`linkify-it`, `postcss`, + `nanoid`, `browserslist`). + +### Changed + +- **`DECKLE_TRUST_PROXY` — set it to `true` behind Caddy, Traefik or nginx.** + Without it, Deckle now throttles by the address that connected, which behind + a proxy is the proxy: every visitor shares one counter, and ten wrong + passwords from anyone lock everyone out for fifteen minutes. For a chain of + proxies (Cloudflare in front of Caddy), give the number instead. See + [Security](README.md#security). +- Completing a recurring task through `PATCH /api/v1/tasks/{id}` rolls its due + date forward, exactly as ticking it in the app does. It used to complete the + task and end the series. + +### Fixed + +- **Autosave could leave a note a paragraph behind the editor.** Two saves could + overlap whenever a write was slow — a server library on a slow link, or a save + that also took a history snapshot — and the older one could finish last, + while the indicator said *Saved*. Saves now happen strictly in order. +- **Tasks and bookmarks could be replaced by an empty list.** + - A change made while `tasks.json` or `bookmarks.json` was still loading was + saved on its own, over the file. It is now applied to what loads. + - A file that wouldn't parse, or came from a newer Deckle, loaded as empty + and was overwritten by the next change. The app now keeps a copy first + (`.deckle/tasks.unreadable-