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-