Skip to content

Harden the server and the app for production, and give Deckle a test suite - #26

Merged
authorTom merged 1 commit into
mainfrom
fix/production-hardening
Sep 15, 2026
Merged

authorTom merged 1 commit into
mainfrom
fix/production-hardening

Conversation

@authorTom

@authorTom authorTom commented Sep 15, 2026

Copy link
Copy Markdown
Owner

A full review of Deckle for production. It fixes six security issues and five ways data could be lost, and adds the project's first test suite, which CI now runs before the build.

Before deploying

Behind Caddy, Traefik or nginx, set DECKLE_TRUST_PROXY=true, or a number for a chain of proxies. Login throttling no longer believes X-Forwarded-For from anyone. Without the setting, every visitor behind a proxy shares one counter, so ten wrong passwords lock everyone out for 15 minutes. This is a new setting with a safe default, so the release is a minor bump (1.7.0) under the versioning policy.

Security

  • API key readable through the file API. A path like ./.deckle-state/assistant.json got past the reserved-folder check, which compared the raw first path segment. The check now runs on the resolved path, ignores letter case, and also covers a custom DECKLE_STATE_DIR inside the library. It also put the key within reach of the assistant's read-only read_file tool.
  • Password guessing wasn't throttled. The client address came from a header any client can set. server/throttle.mjs now takes the address from the connection unless a proxy is trusted, reads the header from the right-hand end, and caps how many addresses it tracks.
  • One unreadable file crashed the server. Files are opened before headers are written, and streamed through pipeline.
  • Sessions. Changing the password signs everyone out, the password check no longer reveals its length through timing, a malformed cookie is treated as signed out, and sign-out needs the app's request header.
  • Content-Security-Policy (script-src 'self') on every response. It still allows calls to any AI provider, including LM Studio on the local network.
  • App-side:
    • Bookmark hrefs are drawn only for http(s) URLs.
    • ZIP inflation is capped at the size the archive declares.
    • AI tool paths, memory paths and the queue's inbox limit reject .. and hidden folders, whatever the storage underneath allows.
  • npm audit fix updates linkify-it, postcss, nanoid and browserslist.

Data loss

  • Autosave: flushes are serialised, so a slow older write can't land after a newer one.
  • Tasks and bookmarks: both now use one useDataFile hook.
    • It doesn't save before the file has loaded; changes made while loading are replayed onto what loads.
    • Saves run in order, and switching libraries writes a pending change to the library it belongs to.
    • A file it can't read is copied aside (.deckle/*.unreadable-*.json) before anything replaces it. The API refuses to write over one, with 500 store_unreadable.
  • Queue: index updates are locked, so concurrent runs can't drop rows or restore a stale status (which could make a run execute twice).
  • API:
    • Completing a recurring task rolls it forward, as the app does.
    • A PATCH is validated before the note is moved.
    • Bodies that aren't JSON objects get a 400.
    • Due dates must be real dates, and colours must be hex.
    • An export whose client disconnects no longer hangs.

Structure

The server's setup moved from server/index.mjs into a side-effect-free server/app.mjs, which the tests drive directly. index.mjs now only boots it. The README, .env.example, compose.yaml, the OpenAPI document and the changelog are updated.

Test plan

  • npm test: 285 tests in 20 files, covering:
    • the server through its real HTTP handler, auth and throttling, and /api/v1
    • the file API, including the reserved-folder and symlink cases
    • ZIP round-trips, zip bombs, and every storage backend (including the remote adapter against a live server)
    • the queue under concurrency, the assistant's tools, and the hooks (useDataFile, and useNotes autosave ordering)
  • npm run build
  • Smoke test in headless Chrome against the built server, with a password: sign-in (including a wrong password), creating a note, typing into it, the note reaching disk, the command palette, and a reload with a javascript: bookmark on disk. No CSP violations and no page errors.
  • Docker image build: not run locally, because no Docker daemon was available. The Dockerfile is unchanged and copies all of server/.

Known and not addressed

There are 35 moderate npm advisories, all from Tiptap v2 (mergeAttributes and __proto__). The fix exists only in Tiptap v3, a major editor migration that belongs in its own PR. Deckle never passes attribute objects that could reach that path, because content comes from Markdown through the DOM parser.

🤖 Generated with Claude Code

…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 <noreply@anthropic.com>
@authorTom
authorTom merged commit d75ed8d into main Sep 15, 2026
1 check passed
@authorTom
authorTom deleted the fix/production-hardening branch September 15, 2026 06:01
@authorTom authorTom mentioned this pull request Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant